---
title: How to Manage Recurring Calendar Events in Capacitor
description: Recurring calendar events in Capacitor are one event plus a rule. Learn how to expand them into occurrences and edit or delete a single one.
date:
  created: 2026-09-22
  updated: 2026-09-22
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor Calendar: sdks/capacitor/calendar.md
faq: true
---

# How to Manage Recurring Calendar Events in Capacitor

A weekly class that runs for a term is one entry in the calendar database, not fifteen. Recurring calendar events in Capacitor apps are stored as a single event plus a repetition rule, and Android and iOS expand that pair into the occurrences the user sees. The model decides what happens when someone skips one session or moves the class from November onwards. The [Capacitor Calendar plugin](../../sdks/capacitor/calendar.md) exposes both views, and two options, `instanceStartDate` and `span`, decide whether a write hits one occurrence, that occurrence and every later one, or the entire series.

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Build and deploy your Capacitor app with Capawesome Cloud" src="https://capawesome.io/assets/banners/cloud-build-and-deploy-capacitor-apps.png?t=1" />
  </a>
</div>

**Key takeaways:**

- A recurring event is one stored event plus an RFC 5545 recurrence rule, which Android and iOS expand into occurrences when your app reads a date range.
- The plugin's `RecurrenceRule` has five fields, `frequency`, `interval`, `count`, `until` and `daysOfWeek`, and `count` takes precedence over `until`.
- `getEvents(...)` returns one entry per occurrence with a shared `id`, so an occurrence is addressed by the pair of `id` and `startDate`.
- Pass that start date as `instanceStartDate` to `updateEventById(...)` or `deleteEventById(...)`, and `span` picks `EventSpan.ThisEvent` (the default) or `EventSpan.ThisAndFutureEvents`.
- EventKit builds both spans into `save` and `remove`, while Android's `Instances` table is read-only, offers exception rows instead and has no "this and future" primitive.

## One event, many occurrences

A recurring event is stored once. The event row carries a recurrence rule, and the calendar expands that rule into occurrences when something reads a range of dates. Apple's EventKit attaches an `EKRecurrenceRule` to the event, and Android keeps the rule in the `rrule` column of `CalendarContract.Events`, where the documentation is explicit that "recurring events will only return a single row regardless of the number of times that event repeats".

The plugin keeps both views available. `getEvents(...)` gives you the occurrence view, one entry per date the user sees in an agenda. `getEventById(...)` gives you the series view, the stored event with its original start date and its rule. Writes default to the series and take one extra option to target an occurrence instead.

To install the Capacitor Calendar plugin, refer to the [Installation](../../sdks/capacitor/calendar.md#installation) section of the plugin documentation. It covers Android and iOS, has no web implementation, and ships as part of [Capawesome Insiders](../../insiders/index.md).

## Writing a recurrence rule

The rule itself is a five-field object, and every field maps onto a part of the `RRULE` property defined by [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10){:target="_blank"}, the iCalendar specification both platforms build on.

| `RecurrenceRule` field | RFC 5545 rule part | What it does |
| --- | --- | --- |
| `frequency` | `FREQ` | `Daily`, `Weekly`, `Monthly` or `Yearly`. Required by the specification. |
| `interval` | `INTERVAL` | Multiplies the frequency. Defaults to `1`. |
| `count` | `COUNT` | Ends the series after a number of occurrences. |
| `until` | `UNTIL` | Ends the series on a timestamp in milliseconds. |
| `daysOfWeek` | `BYDAY` | The weekdays the series lands on. |

RFC 5545 allows only one end condition, since `COUNT` and `UNTIL` must not appear in the same rule. The plugin resolves the clash by letting `count` take precedence over `until` when both are set. A training series that meets twice a week for twelve weeks is a single call to [`createEvent(...)`](../../sdks/capacitor/calendar.md#createevent):

```typescript
import {
  Calendar,
  RecurrenceFrequency,
  Weekday,
} from '@capawesome-team/capacitor-calendar';

const createTrainingSeries = async (calendarId: string) => {
  const startDate = new Date('2026-09-22T18:00:00').getTime();
  const { id } = await Calendar.createEvent({
    event: {
      calendarId,
      title: 'Strength training',
      startDate,
      endDate: startDate + 60 * 60 * 1000,
      recurrence: {
        frequency: RecurrenceFrequency.Weekly,
        interval: 1,
        count: 24,
        daysOfWeek: [Weekday.Tuesday, Weekday.Thursday],
      },
    },
  });
  return id;
};
```

`daysOfWeek` is what lets one weekly rule cover both training days. Without it, the series repeats on whatever weekday `startDate` falls on, and twice-weekly training would need two events and twice the bookkeeping. Leave out `count` and `until` and the series has no end, the state both platforms model with an empty end value: `EKRecurrenceEnd` is `nil` on iOS, and the `lastDate` column is `NULL` on Android.

## Expanding into occurrences

[`getEvents(...)`](../../sdks/capacitor/calendar.md#getevents) returns one entry per occurrence rather than one entry per series. Each entry carries the `startDate` and `endDate` of that occurrence, while `id` and `recurrence` are identical across the whole series, so narrowing a range down to one series is a filter on `id`:

```typescript
import { Calendar } from '@capawesome-team/capacitor-calendar';

const getOccurrencesOfSeries = async (id: string, from: number, to: number) => {
  const { events } = await Calendar.getEvents({ from, to });
  return events.filter(event => event.id === id);
};
```

An occurrence is addressed by the pair of `id` and `startDate`. The plugin has no occurrence identifier, no master id and no detached flag, so the start date you read here is what you hand back on a write. [`getEventById(...)`](../../sdks/capacitor/calendar.md#geteventbyid) answers the other question and returns the series itself with its original start date, or `null` when the event is gone.

An occurrence only exists in a result when your range covers it, which means the range you query for the agenda view is also the range that can produce an `instanceStartDate`. For an all-day series those timestamps are midnight UTC rather than local midnight, a contract the announcement post covers under [all-day events and time zones](./announcing-the-capacitor-calendar-plugin.md#all-day-events-and-time-zones). Occurrences also go stale when the user edits the series in the calendar app, and the `calendarChange` listener is the signal to re-run the query, as [described in the announcement post](./announcing-the-capacitor-calendar-plugin.md#react-to-changes-made-outside-your-app).

## Editing one occurrence

[`updateEventById(...)`](../../sdks/capacitor/calendar.md#updateeventbyid) changes the whole series unless you tell it otherwise. Pass the occurrence's `startDate` as `instanceStartDate` and the write targets that occurrence, while `span` decides the reach and defaults to `EventSpan.ThisEvent`, which leaves everything else alone. Moving one training session an hour later looks like this:

```typescript
import { Calendar, EventSpan } from '@capawesome-team/capacitor-calendar';

const moveOneSession = async (id: string, instanceStartDate: number) => {
  const startDate = instanceStartDate + 60 * 60 * 1000;
  await Calendar.updateEventById({
    id,
    instanceStartDate,
    span: EventSpan.ThisEvent,
    event: {
      startDate,
      endDate: startDate + 60 * 60 * 1000,
    },
  });
};
```

The `event` object is a patch. Properties you leave out keep their current values, so the title, the alerts and the recurrence rule of the series survive a write that only moves two timestamps.

`span` is applied only when `instanceStartDate` is present. Drop the date and the same call moves every occurrence of the series, past ones included. That is the bug to watch for when your UI hands the handler an occurrence the user tapped: the occurrence knows its start date, so pass it through.

## Editing this and future

`EventSpan.ThisAndFutureEvents` widens the same write to the named occurrence and every later one, while earlier occurrences keep the values they already had. A class that moves from 18:00 to 18:30 halfway through a term is one call:

```typescript
import { Calendar, EventSpan } from '@capawesome-team/capacitor-calendar';

const moveClassFromHere = async (id: string, instanceStartDate: number) => {
  const startDate = instanceStartDate + 30 * 60 * 1000;
  await Calendar.updateEventById({
    id,
    instanceStartDate,
    span: EventSpan.ThisAndFutureEvents,
    event: {
      startDate,
      endDate: startDate + 90 * 60 * 1000,
    },
  });
};
```

Sessions before that date stay at 18:00, which is the honest record of when they happened. Which span to use is a question for the user rather than for your code. Both system calendar apps ask it before they save, and an app that writes to a shared calendar should ask too.

## Deleting occurrences

To delete a single occurrence of a recurring event on iOS and Android, pass that occurrence's `startDate` as `instanceStartDate` to [`deleteEventById(...)`](../../sdks/capacitor/calendar.md#deleteeventbyid) and leave `span` at its default of `EventSpan.ThisEvent`. Switching the span to `EventSpan.ThisAndFutureEvents` ends the series from that occurrence onwards:

```typescript
import { Calendar, EventSpan } from '@capawesome-team/capacitor-calendar';

const deleteOccurrence = async (id: string, instanceStartDate: number) => {
  await Calendar.deleteEventById({
    id,
    instanceStartDate,
    span: EventSpan.ThisEvent,
  });
};

const deleteAllFutureOccurrences = async (
  id: string,
  instanceStartDate: number,
) => {
  await Calendar.deleteEventById({
    id,
    instanceStartDate,
    span: EventSpan.ThisAndFutureEvents,
  });
};
```

Both calls use the series `id`, the one you already have from `getEvents(...)`. Omit `instanceStartDate` and the entire recurring event goes, occurrences in the past included. That is the right behavior behind a "delete this series" menu item and the wrong one behind a "skip this week" button, and the only thing separating the two is one option.

## What happens natively

The two platforms hand you very different tools for the same job. iOS builds spans into EventKit: [`EKSpan`](https://developer.apple.com/documentation/eventkit/ekspan){:target="_blank"} is documented as "an object that indicates whether modifications should apply to a single event or all future events of a recurring event", where `thisEvent` means "modifications to this event instance should affect only this instance" and `futureEvents` means they "should also affect future instances of this event". Both `save(_:span:commit:)` and `remove(_:span:commit:)` take that span, so "skip this session" and "move the class from here" differ by one enum case.

Android has no span. [`CalendarContract.Instances`](https://developer.android.com/reference/android/provider/CalendarContract.Instances){:target="_blank"} is where occurrences live, and its documentation states that "the instances table is not writable and only provides a way to query event occurrences". Changing one occurrence means writing an exception into the [`Events`](https://developer.android.com/reference/android/provider/CalendarContract.Events){:target="_blank"} table: `Events.CONTENT_EXCEPTION_URI` is "the content:// style URI for recurring event exceptions" and its insertions "require an appended event ID", with `original_id` and `originalInstanceTime` pointing back at the occurrence being replaced. The provider documents those parts rather than a recipe. It gives you `ORIGINAL_ID`, `ORIGINAL_INSTANCE_TIME`, `ORIGINAL_ALL_DAY`, the `STATUS_CANCELED` value of `eventStatus` and the `exdate` column on the series, and leaves the assembling to the caller. Nothing in it corresponds to `futureEvents`, so "this and all later" has to be assembled from the series' own `rrule` and a second series that starts at the changed occurrence.

The same four operations, side by side:

| Operation | iOS, EventKit | Android, Calendar Provider |
| --- | --- | --- |
| List occurrences | `events(matching:)` with a date-range predicate | Query `Instances`, which is read-only |
| Change one occurrence | `save(event, span: .thisEvent)` | Insert an exception row via `CONTENT_EXCEPTION_URI` |
| Skip one occurrence | `remove(event, span: .thisEvent)` | Exception row with `STATUS_CANCELED`, or the date in `exdate` |
| Change this and all later | `save(event, span: .futureEvents)` | No equivalent; shorten the `rrule` and start a second series |

Both platforms do agree on how an occurrence is named. RFC 5545 identifies an instance with [`RECURRENCE-ID`](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.4){:target="_blank"}, whose "property value is the original value of the DTSTART property of the recurrence instance". Android's `originalInstanceTime` is that value, Apple's `EKEvent.occurrenceDate` is that value, and so is `instanceStartDate`. The plugin does not document which native calls it makes underneath. What it documents is the contract on top, where the same three options, `id`, `instanceStartDate` and `span`, behave the same way on both platforms.

## Reading the rule back

The `recurrence` property of an event is read as well as written, so a series your app did not create can still be described to the user. Reading the rule off the series and turning it into a label takes one call:

```typescript
import {
  Calendar,
  RecurrenceFrequency,
} from '@capawesome-team/capacitor-calendar';

const unitByFrequency: Record<RecurrenceFrequency, string> = {
  [RecurrenceFrequency.Daily]: 'day',
  [RecurrenceFrequency.Weekly]: 'week',
  [RecurrenceFrequency.Monthly]: 'month',
  [RecurrenceFrequency.Yearly]: 'year',
};

const describeRecurrence = async (id: string) => {
  const { event } = await Calendar.getEventById({ id });
  const rule = event?.recurrence;
  if (!rule) {
    return 'Does not repeat';
  }
  const unit = unitByFrequency[rule.frequency];
  const cadence = rule.interval > 1 ? `every ${rule.interval} ${unit}s` : `every ${unit}`;
  if (rule.count) {
    return `Repeats ${cadence}, ${rule.count} times`;
  }
  if (rule.until) {
    return `Repeats ${cadence} until ${new Date(rule.until).toLocaleDateString()}`;
  }
  return `Repeats ${cadence}`;
};
```

Rules that arrive from the calendar app or from a synced Google or Exchange calendar can use parts the plugin does not model, such as `BYMONTHDAY` or `BYSETPOS`. Those come back as the closest supported subset, with `frequency` and `interval` always correct, so a label may read "every month" where the stored rule says "the third Thursday of every month". Render the label from `recurrence` and take the actual dates from `getEvents(...)`, which reports the occurrences the platform computed instead of the ones your renderer would infer.

## Other calendar plugins

The Capacitor Calendar plugin is not the only one that can target an occurrence. `@ebarooni/capacitor-calendar` (8.7.0, MIT) documents `deleteEvent({ id, instanceDate, span })` for deleting a single occurrence on Android and iOS, and its rule type covers parts this plugin does not model at all: `byMonth`, `byMonthDay` and `byWeekDay`, plus `daysOfTheYear` and `weeksOfTheYear` on iOS. It also has an iOS Reminders API, a partial web implementation that exports an `.ics` file, and an MIT license. If any of those decide your feature, take it.

The differences show up on the other operations, as documented in its README on 2026-09-21. Its `ModifyEventOptions` carries a `span`, but the option is marked iOS-only and has no `instanceDate` beside it, so an edit scoped to one occurrence and keyed to a date is documented for one platform. Its `CalendarEvent` has no `recurrence` property, so reading a series' rule back off an event is not documented, which is not the same as impossible. And the correct call differs per platform and per method: its own documentation warns that on Android `THIS_EVENT` cannot target a single recurring occurrence in `deleteEventsById` and that you have to use `deleteEvent` with `instanceDate` instead.

The announcement post carries the full [comparison of the three Capacitor calendar plugins](./announcing-the-capacitor-calendar-plugin.md#which-capacitor-calendar-plugin-supports-recurring-events), including `@capgo/capacitor-calendar`, row by row.

## FAQ

### How do you delete a single occurrence of a recurring event on iOS and Android?

Pass the occurrence's `startDate`, as returned by `getEvents(...)`, to `deleteEventById(...)` as `instanceStartDate`, and leave `span` at `EventSpan.ThisEvent`. The same call with `EventSpan.ThisAndFutureEvents` removes that occurrence and every later one. The platforms get there differently, EventKit with a span on the remove call and Android with an exception row for the series, but the call from your app is identical on both.

### Can I move one occurrence to another day?

Yes. Call `updateEventById(...)` with `instanceStartDate`, `span: EventSpan.ThisEvent` and an `event` patch that carries the new `startDate`. Properties you leave out keep their current values, so pass `endDate` as well unless the occurrence should keep its old end time. Everything else in the series stays where it was.

### Why do all occurrences share one id?

Because there is only one stored event. `id` identifies that event, and every occurrence returned by `getEvents(...)` carries it, which mirrors how EventKit and the Android Calendar Provider store a series. It also means `id` alone cannot address an occurrence, which is why the write methods take `instanceStartDate`.

### Does editing one occurrence break the series?

No. With `EventSpan.ThisEvent`, the recurrence rule is untouched and every other occurrence keeps its values. What applies a write to the entire series is omitting `instanceStartDate`, so pass it whenever the user acted on a single occurrence rather than on the series.

### What if a rule uses parts the plugin does not support?

It is read back as the closest supported subset, with `frequency` and `interval` always correct. A rule such as "the last Friday of every month", written in the calendar app, can come back as a monthly rule without the day constraint. Use the rule for a label and `getEvents(...)` for the real dates.

### Does Android need read permission to write?

Yes. `createEvent(...)`, `updateEventById(...)` and `deleteEventById(...)` need `READ_CALENDAR` on top of `WRITE_CALENDAR`, because they look up the calendar or the event before writing. Only `createCalendar(...)` and `deleteCalendarById(...)` get by with `WRITE_CALENDAR` alone. On iOS 17 and newer, every method of the plugin needs full calendar access, not write-only access.

### Is the Capacitor Calendar plugin free?

No. It is part of the [Capawesome Insiders](../../insiders/index.md) subscription, which covers every Insiders plugin and priority support from the Capawesome team. It supports Android and iOS and has no web implementation.

## Stay in the loop

New Insiders plugins and notable plugin releases go out in the Capawesome newsletter before they are announced anywhere else.

[Subscribe to the Capawesome Newsletter](https://capawesome.io/newsletter/){ .md-button .md-button--primary }

## Conclusion

One decision covers most work with recurring events: pass `instanceStartDate` whenever the user acted on an occurrence rather than on the series, and let `span` decide how far the change reaches. Wire the occurrence's start date through your UI layer from the moment you render the agenda, and the two write methods stay a one-line change apart.

If you are still choosing between the options, the [announcement post](./announcing-the-capacitor-calendar-plugin.md#which-capacitor-calendar-plugin-supports-recurring-events) compares the Capacitor calendar plugins row by row, and the [Capacitor Calendar plugin](../../sdks/capacitor/calendar.md) documentation has every method and option. If you have any questions, join us on the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}. To stay updated on the latest news, subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"}.
