How Calendar Sync Actually Works: Sync Tokens, Recurrence, and Deletions

By Rida F'kih · August 11, 2026 · Updated August 12, 2026

Calendar sync looks like a solved problem from the outside. You read events from one calendar, you write them to another, and you do it on a timer. I thought that too, then I built Keeper.sh and spent a year finding out where the bodies are buried. What follows is what a real sync engine has to do, written from ours: the mechanisms, and the failure modes that forced them.

The shape of the problem

The naive model of sync is a pipe: source calendar in, destination calendar out. A pipe has no memory, so it breaks the first time anything goes wrong. Delete an event on the destination and you don't know whether to put it back. Delete it on the source and you don't know which destination event corresponded to it.

So the real model has three stores, not two:

  1. Remote source state. What the provider says is on the calendar right now.
  2. Local state. Our own copy of the source events, in our database.
  3. Mappings. For each destination calendar, a row per event we created there: which local event it came from, what identifier it has remotely, what identifier we need to delete it, and a hash of the content we pushed.

Ingest moves (1) into (2). Reconciliation compares (2) against the destination and updates (3). Keeping them separate is the most useful structural decision in the engine: ingest can fail without corrupting destinations, and reconciliation never touches a source.

Incremental sync: tokens, delta links, and the 410

Polling a calendar in full every minute is fine for a hundred events and hostile for ten thousand. Google and Microsoft both offer an incremental protocol with the same shape: request everything once, get back an opaque token, replay the token next time and receive only what changed.

For Google Calendar, the token arrives as nextSyncToken on the last page of an events.list response, and you replay it as the syncToken parameter. The constraint, per Google's guide to synchronizing resources efficiently, is that a sync token cannot be combined with most query parameters. The request you make with a token is not the request you made without one. Our full request is bounded, timeMin seven days back and timeMax two years out; the incremental request carries no bounds at all, because it isn't allowed to. We re-apply the window ourselves after the response arrives, and events outside it are dropped, then deleted if we'd stored them before.

For Microsoft Graph, the equivalent is a delta query. We call calendarView/delta with a startDateTime and endDateTime, page through @odata.nextLink, and the final page carries an @odata.deltaLink — the whole request, URL and all, replayed verbatim next time. Graph's delta query overview covers the pattern; the calendarView delta reference matters more here, because the delta stays scoped to the window you originally asked for.

Then the failure everybody hits. Tokens expire, and both providers answer with HTTP 410 Gone: your token is too old, the change history you'd need is gone, start over. It isn't an error condition. It's a normal state transition that happens to every long-lived integration, and the only response is to discard the token, do a full bounded fetch, and rebuild.

Ours handles 410 at the page level: any page that returns it aborts the whole pull and reports that a full sync is required. The layer above clears the stored token and immediately re-runs the fetch without one. Waiting for the next cron tick would leave the calendar stale for a whole cycle, and that wait stacks on top of the write half — half an hour of it on the free plan.

We also expire tokens ourselves. A stored token carries a version encoding the sync-window shape in force when it was issued, plus a time bucket rolling over about every seven days; either one moving on drops the token and forces a full fetch. The window part is defensive: widen the window in a release and every existing token was minted against a narrower one, so the newly-covered range would silently never backfill. The seven-day part hedges against drift, since a single mishandled tombstone persists until something forces a full read. The bucket is offset by a hash of the calendar ID, so calendars don't all resync on the same day and stampede the API.

Why our CalDAV source has no sync token

CalDAV is the odd one out. Our CalDAV source returns a null sync token on every cycle, by construction: it fetches a calendar-query REPORT over the entire time window each time and diffs the whole result against stored state.

There is a standard for doing better. RFC 6578 defines WebDAV sync-collection, a sync token over a collection that reports changed and removed members. Server support is uneven, and the report is scoped to the collection rather than to a time range, so on a calendar with a decade of history it hands you a firehose you then have to filter anyway. A bounded window turned out simpler and, at our window size, not much more expensive.

When you fetch everything, deletion detection is free. An event missing from the response is deleted — no tombstone to miss, no token to mishandle. Every bug in the next section exists only because incremental sync trades that guarantee away.

Deletions, tombstones, and the ways they go wrong

In a full listing, "gone" means "absent". In a delta, absence means nothing — a delta only contains what changed, so almost everything is absent from almost every response. Deletion has to be reported explicitly, and each provider reports it differently.

Google marks a deleted event status: "cancelled" and includes it in the delta. Graph emits a tombstone object carrying an @removed annotation. In both cases you extract the IDs, delete the corresponding local rows, and exclude the tombstones from the set of events you're about to store.

Two failure modes here cost us real bugs.

The first is that a delta response can contain the same event ID more than once. You're paginating a live calendar, someone edits an event between page two and page five, and you get two versions; in arrival order you may persist the older one. We key changed events by provider ID and keep the highest revision — updated on Google, lastModifiedDateTime on Graph, falling back to creation time when the modification stamp is missing.

The second is specific to Graph. calendarView/delta normally returns expanded occurrences rather than recurring masters, which is what you want. But a round can hand you a seriesMaster anyway, or an @removed tombstone with no type on it at all. A bare ID with no type could be a series master, and our local state for that series is a set of expanded occurrences under different IDs. Deleting the one row whose ID matches and then advancing the delta link would strand every occurrence of that series in our database forever, invisible to all future deltas. So when either shows up, we throw the whole delta away and force a full resync of that calendar. The alternative is silent, permanent, per-series corruption nobody notices for months.

Dropping a delta costs one expensive request. Advancing past a tombstone you didn't understand costs correctness until someone reconnects the account.

Deduplication, and detecting change without trusting anyone

Both passes answer "is this the same event I already have?" differently.

On ingest we build an identity key per event covering essentially all of it: provider event ID, UID, an instance key, start and end, all-day flag, availability, event type, title, description, location, timezone, and the full recurrence triple of rule, exception dates, and recurrence ID. The serialization is stable, keys sorted and arrays canonicalized, because otherwise two structurally identical recurrence rules hash differently depending on the order a parser emitted their fields, and you rewrite the entire calendar every cycle for nothing. Events whose identity key already exists are skipped: no write, no version bump, no downstream churn.

Incoming events are also deduplicated against each other before the diff, keyed by provider ID where one exists and by a UID-plus-instance key where one doesn't. iCal feeds in particular will hand you the same VEVENT twice without apology.

On the push side there are two hashes.

The sync hash covers everything that determines what we'd write: summary, description, location, availability, all-day, start, end, timezone, recurrence rule, recurrence duration, exception dates, recurrence ID. It lives on the mapping. When the local event's sync hash stops matching it, the source changed and the destination copy is stale.

The editable hash covers only summary, description, location, and all-day — the fields we can reliably read back from a destination event across every provider. It's the drift detector. Each reconciliation lists the events we own on the destination, recomputes this hash from what's actually there, and overwrites anything that no longer matches what we'd produce today.

The same pass checks two things the hash can't see. Availability drift is compared against what we'd have written given what that destination supports: Google expresses busy and free, Graph also out-of-office and working-elsewhere, so the expected value is provider-dependent. Time drift is compared at whole-second granularity, deliberately, because providers round sub-second precision and a strict comparison flags every event on every cycle forever.

A stale mapping is fixed by replacement: delete the remote event, create it fresh, swap the mapping. No partial updates. Update semantics differ enough between providers, especially around recurrence and attendees, that delete-and-recreate is the only operation with the same meaning everywhere.

Loop prevention

Mirror A into B and B into A, and without a marker you get a fork bomb. A's event lands in B, B's copy lands back in A, and by the fourth cycle the user has sixteen copies of a dentist appointment. Pushed events have to be identifiable as ours, and every source has to skip them.

Everywhere UIDs are writable, we mint one deterministically: SHA-256 over the local event ID and the destination calendar ID, truncated, with @keeper.sh appended. Two properties matter. It's deterministic, so a retry after an ambiguous failure targets the same object rather than creating a second one. And it's suffixed, so recognizing our own work is a string check with no database lookup, which is what lets an ingest path filter loops before it has any context at all.

Every source ingest drops events whose UID ends in that suffix: Google's by iCalUID, CalDAV's the same, and because we store each pushed event at <uid>.ics, the destination listing can filter by filename before parsing a byte of iCalendar. On Google the write goes through events.import rather than events.insert, because import accepts a caller-supplied iCalUID and upserts on it, so re-pushing an event updates it instead of returning a conflict.

Outlook doesn't let you do any of this. Graph assigns iCalUId itself on create, with no way to supply one. So the marker moves to a category: every event we create carries a keeper.sh category, the Outlook source skips anything carrying it, and the destination listing uses it to decide which events are ours to manage. That category is visible in the Outlook UI and a user can remove it. Graph offers nothing sturdier, so the Outlook path is built around losing it: an unmarked event is treated as the user's own and never deleted. The next reconciliation stops managing it and creates a fresh copy alongside it, and if that calendar is also a source, the unmarked event ingests as a real one and starts copying onwards. A duplicate the user can delete, rather than a deletion they cannot undo.

Recurrence: masters, overrides, and DST

RFC 5545 models a repeating event as a master VEVENT with an RRULE, plus EXDATE for cancelled slots, plus separate VEVENTs carrying RECURRENCE-ID for modified ones. A standup cancelled once and moved once is three components. Reconstructing what the user sees means expanding the rule, subtracting the exception dates and the slots claimed by overrides, then adding the overrides back at their new times.

You may not have to do this at all. We ask Google for singleEvents=true, which expands the series server-side, and where a Graph master slips past the delta we expand it via the /instances endpoint. So no recurrence rule is ever stored for our OAuth sources. Full recurrence structure only reaches our database from CalDAV and ICS, where raw iCalendar is what's on the wire — the same calendar stored two entirely different ways depending on origin, both paths obliged to produce the same output.

When we expand a rule ourselves, what matters is the domain you expand in. A weekly 09:00 meeting in America/Toronto crossing the March DST boundary is 09:00 local on both sides, which is not a fixed number of milliseconds apart. Expand in absolute time and every occurrence after the transition lands an hour off. So we convert the series start into the wall-clock domain of its TZID, expand there, and convert each occurrence back to an instant. UNTIL gets the same treatment. It's the most common recurrence bug in calendar tooling, and invisible until a Sunday in March.

Occurrence duration comes from the master's DURATION where it has one, applied nominally rather than as a millisecond delta. An event defined as one day long stays one day long across a transition; one defined as 86,400,000 milliseconds does not.

Slots covered by an EXDATE, and slots claimed by an override's RECURRENCE-ID, drop out of the expansion; the override flows through as its own event at its own times. Overrides match a master only when the series has exactly one unambiguous master in the batch — two masters for the same UID and we don't guess.

Expanded occurrences need stable IDs. Ours are synthetic: a hash of the calendar, the source UID, the master's start and end, and the occurrence's own start and end. Stable while nothing changes — and every occurrence ID changes the moment the master moves, because the master's times are in the hash. A naive engine reads that as the entire series deleted and recreated, and deletes and recreates every occurrence on the destination. The user watches their whole recurring meeting flicker.

So there's a re-pairing step. New occurrence IDs with no mapping, and mappings whose occurrence IDs no longer exist, are grouped by the event they descend from and matched: first by identical start/end slot, then by order for the remainder. If the paired remote event is still verifiably correct — same times, matching editable hash, matching availability — we rewrite the mapping row and touch nothing remotely. Otherwise we replace it. Most master edits that don't move the occurrences cost zero remote writes.

There's a guardrail. An RRULE with SECONDLY frequency and no BY* selectors generates tens of millions of occurrences in a two-year window and takes the process with it. Series are capped at 10,000 occurrences, and unfiltered high-frequency rules are checked arithmetically — computing how many occurrences the interval implies — before expansion starts, so a hostile rule is rejected without ever being enumerated. The check runs at ingest, before anything is persisted. A series that blows the budget is withheld with its UID logged, so the blast radius is one series rather than one calendar: nothing pathological is stored and nothing already synced is corrupted.

Finally: we never write recurrence rules to destinations. Every occurrence is pushed as an individual, standalone event. That costs writes, a lot of them on a busy calendar. What it buys is never reasoning about what "edit this occurrence" means on four providers with four interpretations. The exception is the aggregated iCal feed, which emits real masters with RRULE, EXDATE, and RECURRENCE-ID overrides — it's consumed by clients that expect proper iCalendar, and there's no write path to go wrong.

All-day events and timezones

RFC 5545 distinguishes a DATE value from a DATE-TIME value, and an all-day event is one whose DTSTART is a bare date. DTEND is exclusive, so a single-day event on the 5th ends on the 6th — the source of roughly every off-by-one-day bug in the genre.

Providers disagree on how to tell you. Google returns start.date instead of start.dateTime. Graph gives you an explicit isAllDay boolean. iCalendar gives you the value type. Plenty of sources give you nothing useful, so we infer: a duration that's a whole multiple of 24 hours with both ends exactly on UTC midnight is all-day, and an explicit provider flag always beats the inference. There's a second, opt-in interpretation for a case real servers produce — an event marked timed that runs local midnight to local midnight on a later date — reinterpreted as all-day, using the event's own timezone where it has one and the calendar's default where it doesn't.

Writing them back out, every provider wants something different. iCalendar wants a bare DATE. Google wants date rather than dateTime. Graph wants a datetime with the all-day flag set. Timed events are worse: for CalDAV we emit DTSTART;TZID=<zone> with the local wall-clock time, for Graph wall-clock plus an explicit timeZone, and for Google a UTC instant. The stored instant never changes — only its representation does.

VTIMEZONE is the piece most implementations skip, and skipping it is legal-ish but rude: a TZID referencing a zone the client doesn't know, with no definition attached, is unresolvable. We emit them in the aggregated feed, generated from the IANA database via Intl by walking actual offset transitions across a projection window of about a century, then checking whether those transitions collapse into exactly two stable annual patterns. Most zones do, and for those we emit two RRULE-based observances instead of a hundred and fifty literal ones — what RFC 5545 §3.6.5 intends, and what keeps the feed from being megabytes of DST history. Zones with irregular political history fall back to enumerating every transition, correctly, if verbosely.

Ordering, concurrency, and why deletes are dangerous

Reconciliation produces three operation types: add, remove, and replace. They're sorted by event time, then executed in chunks, and within each chunk removes run first, then replaces, then adds. Ordering deletes ahead of creates frees a UID before anything tries to reuse it, which matters on providers that enforce uniqueness on it.

Removes come from three different places:

  • A mapping exists, the local event is gone → delete the remote event. Normal deletion propagation.
  • A remote event carries our marker and has no mapping at all → delete it. This is orphan cleanup, and it's the dangerous one.
  • A mapping's local event is gone, its event ended before the sync window started, and no remote copy appears in the listing → just delete the mapping row. Nothing remote to delete; the event aged out.

Orphan cleanup exists because writes fail. We create an event remotely and then persist the mapping; a crash between those two steps leaves an event on the destination that nothing points at, and without a sweep it lives forever. But the same sweep, run against a partially-loaded view of local state, will cheerfully delete a user's entire mirrored calendar. Everything about how the run is structured is downstream of that risk.

Concurrency is a per-calendar generation counter in Redis. Each run increments it, holds the value it got, and re-checks between operation chunks. If the counter has moved, a newer run started and this one is no longer authoritative, so it stops where it is rather than finishing against stale state. With a per-calendar ingest lock, that keeps two overlapping cron ticks from fighting.

Mappings are flushed after each chunk rather than at the end, so an interruption doesn't lose the record of what was already created. Events inserted during the run are also protected from removal later in the same run — a set of UIDs the orphan sweep won't touch — because a mapping created two chunks ago might not be in the snapshot the sweep is reasoning about.

The gap is still real: between a successful remote create and the flush that records it, a hard crash leaves an orphan. The next run deletes it and creates a replacement, and the user sees an event blink. The alternative is an accumulating pile of undeletable duplicates.

Deletes are treated as idempotent throughout. Both 404 and 410 on a delete count as success, because both mean the event isn't there, which is the state we were trying to reach.

If you came here because you have two calendars rather than because you are building this, none of the above is yours to solve. Keeper.sh runs it hosted: two calendar accounts and three connections between them at no charge, and $5 a month for unlimited connections. An edit on one calendar reaches the other in about a minute on Pro, two at the outside; on the free plan, usually about fifteen minutes, thirty-one at the very worst.

The whole engine is open source, so if any of this sounded like it must be more complicated than I've made it out to be — it is, and you can go read it. *

Can Keeper.sh use cookies for analytics?