Skip to content

Two-way calendar sync with Google Calendar and Outlook: the architecture, and the edge cases that break it

Updated

TL;DR

Two-way sync is a loop: a full sync of a time window, then incremental syncs (Google's syncToken, Graph's delta query) triggered by push notifications and by a schedule, and writes back with a version check. It breaks on the edges: your own writes coming back as changes, deletions that arrive as cancelled entries or @removed, recurring series that change as a whole or one occurrence at a time, Outlook IDs that change when an event moves, and two people editing the same event.

The loop

Every two-way calendar sync has the same parts:

  1. A full sync of a window, such as 30 days back to a year ahead, into your own store.
  2. Incremental syncs that fetch only what changed since the last one.
  3. Push notifications that tell you when to run an incremental sync, and a schedule that runs one anyway, because push is not reliable.
  4. Writes back to the calendar, with a version check so you don't overwrite someone else's edit.

Each provider does each part differently, and the bugs live where your own writes, deletions and recurring events meet the loop.

Full sync and incremental sync

Google. List events, page by page, until the last page returns nextSyncToken, which is "present only on the very last page." Next time, send it as syncToken and you get only changes, including deletions: "the result will always contain deleted entries". A sync token can't be combined with timeMin, timeMax, q, orderBy, updatedMin or the extended property filters, and every request in the chain must use the same parameters. Google says "sync tokens are invalidated by the server, for various reasons including token expiration", and an invalid one answers 410 Gone, which "should trigger a full wipe of the client's store and a new full sync." Google's sync guide covers the details.

Outlook. Start with GET /me/calendarView/delta?startDateTime=...&endDateTime=..., follow @odata.nextLink to the end and keep the @odata.deltaLink. The window is fixed at that point: the tokens "encode the startDateTime and endDateTime parameters". $select, $filter and $orderby aren't supported. Microsoft's delta query overview warns that token lifetime for Outlook "isn't fixed; it's dependent on the size of the internal delta token cache", that an expired token comes back as an error such as syncStateNotFound or a 410 Gone, and that your app "must be prepared for replays" and shouldn't "assume a specific sequence of the responses."

Two things to know about Graph's deletions: they arrive as { "id": ..., "@removed": { "reason": "deleted" } }, and the list also includes events "outside the date range" that were added, deleted or updated. Filter @removed against your own window, or you'll delete events you never stored.

Push is a trigger, not the data

Neither calendar provider sends you the changed event. A Google notification "do[es] not contain specific information about updated resources"; a Graph notification on events says which event, but Microsoft lists event notification latency as "Unknown". Treat every notification as "something changed, run an incremental sync", and run one on a schedule as well, because "Notifications are not 100% reliable" on Google and "Dropped notifications can't be recovered" on Graph. The details of keeping channels and subscriptions alive are in calendar webhooks that keep working, and the trade-offs in webhooks vs polling.

The edge cases that break sync

Your own writes come back

When your app updates an event, the next incremental sync reports that same change. If your sync writes it back, or treats it as the user's edit, you get loops or false "the user changed this" signals.

Comparing version stamps doesn't settle it. On Outlook.com we saw an event's changeKey move 0.5 to 2.5 seconds after it was created, with nothing else changed. On Google, updated doesn't move for some changes: "Updating event reminders will not cause this to change." Compare the content you care about instead (title, times, attendees, location, description) as a digest, and record which events you just wrote so a change you caused can be recognized when it comes back.

Deletions don't look like deletions

On Google, a deleted event arrives as an entry with "status": "cancelled", and only its id is guaranteed. A cancelled occurrence of a recurring series is different: it means that one occurrence "should no longer be presented", and carries recurringEventId and originalStartTime. Read by ID, a deleted Google event still returns 200 with "status": "cancelled", not a 404. On Graph, a deletion is an @removed entry, filtered to your window as above.

Recurring series change two ways

A user can change a whole series or one occurrence. Google marks an occurrence with recurringEventId and originalStartTime, which "uniquely identifies the instance within the recurring event series even if the instance was moved". Graph gives every event a type: singleInstance, occurrence, exception or seriesMaster. Note the opposite iCalUID behaviour: on Google every occurrence shares the series' iCalUID, while Graph's iCalUId "is different for each occurrence in a recurring series". Key your store on the provider's event ID plus the series, not on iCalUID. Recurrence itself is covered in the APIs side by side.

Outlook IDs change

A Graph event's id changes when the event moves to another folder or calendar. Send Prefer: IdType="ImmutableId" on every request to get IDs that stay put, and use the same header everywhere, because the two ID kinds don't match each other.

Two people edit the same event

Send the version you last read with your write: Google's etag as If-Match, where a stale one returns 412 and the fix is to "Re-fetch the entity and re-apply the changes", or Graph's @odata.etag. Creates can't be conditional on Google ("There is no support for conditional modifications for insert operations"), which is why idempotent creates matter.

The first sync of many accounts

A full sync of a year of events is many pages per calendar. Google allows 600 requests per minute per user and 10,000 per project, and suggests spreading scheduled work rather than running it all on the minute. Outlook allows four requests at a time and 10,000 per 10 minutes per mailbox. Queue first syncs per account and back off on 429 with Retry-After.

The window moves

Events slide into the far end of your window and out of the near end every day. Re-read the window periodically and treat events leaving it as "no longer tracked", not as deletions your users made.

How Pyas does it

Pyas runs this loop for every connected Google and Outlook calendar and sends you one signed webhook per change:

  • A window of 30 days back to a year ahead, synced with Google's sync tokens and Graph's delta queries, with an expired token handled by re-reading the window and reporting only the real differences.
  • Changes compared by content. For each event Pyas keeps only its IDs, its start and a fingerprint of the fields that matter, so a stamp that moves on its own, or a reminder change, isn't reported as an edit. Pyas keeps no copies of the events themselves.
  • One webhook per change, and one per series when a whole recurring series changes, with deletions reported as deletions on both providers.
  • Your own writes marked. A webhook caused by your API request carries that request's traceId; a change made in Google Calendar or Outlook carries null. Your handler knows which changes came from you.
  • Push and a safety net. Channels and subscriptions are renewed before they expire, and every account is checked on a schedule, so a lost notification is caught within hours.
  • Version checks that work. Every event has a version; send it as If-Match and a stale update is refused with a conflict.

The two-way sync use case shows the whole flow in code, and the sandbox lets you simulate edits made in Google Calendar or Outlook to test your handler.

How to build calendar webhooks that keep working (Google push channels and Microsoft Graph subscriptions)Webhooks vs polling for Google Calendar and Outlook changesOutlook.com changes an event's change key seconds after creating it