<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vaibhav Pathak</title>
    <description>The latest articles on DEV Community by Vaibhav Pathak (@vkpdeveloper).</description>
    <link>https://dev.to/vkpdeveloper</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F349939%2F8ebf6713-5009-4eba-b1e0-f8fbcbbe655e.jpeg</url>
      <title>DEV Community: Vaibhav Pathak</title>
      <link>https://dev.to/vkpdeveloper</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vkpdeveloper"/>
    <language>en</language>
    <item>
      <title>Three ways a calendar tells you what changed</title>
      <dc:creator>Vaibhav Pathak</dc:creator>
      <pubDate>Thu, 05 Nov 2026 19:42:45 +0000</pubDate>
      <link>https://dev.to/vkpdeveloper/three-ways-a-calendar-tells-you-what-changed-5gaf</link>
      <guid>https://dev.to/vkpdeveloper/three-ways-a-calendar-tells-you-what-changed-5gaf</guid>
      <description>&lt;p&gt;Sooner or later, every app that shows a user's calendar wants its own copy of it. You want to search events in your database, join them with your own data, and show a week without waiting on three slow APIs. So you copy the calendar into a table, and then you have the real problem: keeping that table correct without downloading the whole calendar every five minutes.&lt;/p&gt;

&lt;p&gt;Google Calendar, Microsoft Outlook and CalDAV servers like iCloud can all tell you "here's what changed since last time". They just do it in three quite different ways, and each one breaks in its own way. Here's what I learned wiring up all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do Google Calendar sync tokens work?
&lt;/h2&gt;

&lt;p&gt;Google has the cleanest model. You list a calendar's events once with no filters, follow &lt;code&gt;pageToken&lt;/code&gt; through every page, and the last page hands you a &lt;code&gt;nextSyncToken&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /calendar/v3/calendars/primary/events?maxResults=2500

{ "items": [ ... ], "nextPageToken": "CiAKGjBpNDd2..." }
...
{ "items": [ ... ], "nextSyncToken": "CPDAlvWDx70CEPDAlvWDx70CGAU=" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next time, you send that token back and get only what changed since then. Deleted events come back too, as items with &lt;code&gt;"status": "cancelled"&lt;/code&gt;, and Google always includes them on a sync. You can't hide them, and you wouldn't want to, because that's how your copy learns about deletions.&lt;/p&gt;

&lt;p&gt;The catch is that the token remembers the query that created it. The &lt;code&gt;events.list&lt;/code&gt; reference names the parameters you can't combine with &lt;code&gt;syncToken&lt;/code&gt;: &lt;code&gt;iCalUID&lt;/code&gt;, &lt;code&gt;orderBy&lt;/code&gt;, &lt;code&gt;privateExtendedProperty&lt;/code&gt;, &lt;code&gt;q&lt;/code&gt;, &lt;code&gt;sharedExtendedProperty&lt;/code&gt;, &lt;code&gt;timeMin&lt;/code&gt;, &lt;code&gt;timeMax&lt;/code&gt; and &lt;code&gt;updatedMin&lt;/code&gt;. The one that bites people is &lt;code&gt;timeMin&lt;/code&gt;. It's natural to sync "the next three months", but a sync token covers the whole calendar, so your first sync has to read the whole calendar too. Everything else you pass should match the initial request exactly.&lt;/p&gt;

&lt;p&gt;Tokens also expire, for example after a while unused, or when the calendar's sharing changes. Google then answers &lt;code&gt;410 Gone&lt;/code&gt;, and the only fix is the one Google recommends: wipe your copy and do a full sync again. There's no way to resume from a partial state.&lt;/p&gt;

&lt;p&gt;One more detail: if you sync without &lt;code&gt;singleEvents=true&lt;/code&gt;, a recurring meeting arrives as one series event with an RRULE, and any occurrence someone moved or edited arrives as its own exception event. That's what you want for a copy, since an endless daily standup has no finite list of occurrences to sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Outlook delta sync work?
&lt;/h2&gt;

&lt;p&gt;Microsoft Graph uses delta queries, and for calendars the one that works across a whole range is &lt;code&gt;calendarView/delta&lt;/code&gt;. Unlike Google, it requires a window up front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /me/calendarView/delta?startDateTime=2025-11-06T00:00:00Z&amp;amp;endDateTime=2028-11-06T00:00:00Z
Prefer: odata.maxpagesize=200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each page ends with either an &lt;code&gt;@odata.nextLink&lt;/code&gt; (more pages in this round) or an &lt;code&gt;@odata.deltaLink&lt;/code&gt; (the round is done). You don't build the next request yourself: Graph encodes your parameters, the window included, into those links, and you call them as they are. The delta link is your sync cursor.&lt;/p&gt;

&lt;p&gt;That has a consequence that's easy to miss: the window is fixed when you start. If you begin syncing on November 6th with "the next two years", you're still syncing that same two years next summer. To move the window you start a new round from scratch.&lt;/p&gt;

&lt;p&gt;Deletions come back as stubs with an &lt;code&gt;@removed&lt;/code&gt; annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AAMkAGI2..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@removed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deleted"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Graph's docs warn that &lt;code&gt;@removed&lt;/code&gt; covers two cases: events in the window that were deleted, and events outside the window that changed. An event moved from inside your window to outside it looks exactly like a deletion, which, for a copy of that window, is the right answer anyway.&lt;/p&gt;

&lt;p&gt;Two more things surprised me. The same event can appear more than once in a round, so you have to apply changes in order and let the later one win. And recurring meetings come back as a series master plus every occurrence in the window, where unmodified occurrences are thin stubs. For a copy, you keep the master and the exceptions and drop the stubs. To know which occurrences of a series were deleted, you read the master's &lt;code&gt;cancelledOccurrences&lt;/code&gt;, which costs one more request per changed series.&lt;/p&gt;

&lt;p&gt;Expiry looks different too. Depending on the case, Graph answers &lt;code&gt;410 Gone&lt;/code&gt; or an error code like &lt;code&gt;SyncStateNotFound&lt;/code&gt;, &lt;code&gt;SyncStateInvalid&lt;/code&gt; or &lt;code&gt;resyncRequired&lt;/code&gt;. All of them mean the same thing: start over.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you sync an iCloud or CalDAV calendar?
&lt;/h2&gt;

&lt;p&gt;CalDAV is the protocol behind iCloud, Fastmail, Nextcloud, Zoho and many self-hosted servers. It speaks WebDAV over XML, and its sync story has two generations.&lt;/p&gt;

&lt;p&gt;The old way is the CTag, a calendarserver.org extension: one &lt;code&gt;getctag&lt;/code&gt; property on the calendar that changes whenever anything in it changes. It tells you &lt;em&gt;that&lt;/em&gt; something changed, not &lt;em&gt;what&lt;/em&gt;. So you then list the ETag of every event resource with a &lt;code&gt;PROPFIND&lt;/code&gt;, compare them with the ETags you stored, and fetch the ones that differ. It works, but every check reads the whole calendar's list of ETags.&lt;/p&gt;

&lt;p&gt;The modern way is RFC 6578's &lt;code&gt;sync-collection&lt;/code&gt; REPORT. You send the token from last time (an empty one for the first sync):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;D:sync-collection&lt;/span&gt; &lt;span class="na"&gt;xmlns:D=&lt;/span&gt;&lt;span class="s"&gt;"DAV:"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;D:sync-token&amp;gt;&lt;/span&gt;http://example.com/sync/1234&lt;span class="nt"&gt;&amp;lt;/D:sync-token&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;D:sync-level&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/D:sync-level&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;D:prop&amp;gt;&amp;lt;D:getetag/&amp;gt;&amp;lt;/D:prop&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/D:sync-collection&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server answers with the resources that changed (with their new ETags), the ones that were removed (with a &lt;code&gt;404 Not Found&lt;/code&gt; status), and a new token. Then you fetch the changed ones' iCalendar data with a &lt;code&gt;calendar-multiget&lt;/code&gt; REPORT, in batches.&lt;/p&gt;

&lt;p&gt;There are a few rough edges. A server can truncate the answer, which it signals with a &lt;code&gt;507 Insufficient Storage&lt;/code&gt; status on the collection itself, and you ask again from the new token. A token the server no longer accepts fails the &lt;code&gt;DAV:valid-sync-token&lt;/code&gt; precondition, usually as a &lt;code&gt;403&lt;/code&gt; or &lt;code&gt;409&lt;/code&gt; (some servers use &lt;code&gt;410&lt;/code&gt;), and you start over. Some servers also list long-deleted resources in an initial sync, so you can't treat every &lt;code&gt;404&lt;/code&gt; as "delete this" when you had nothing to begin with.&lt;/p&gt;

&lt;p&gt;And one CalDAV resource is a whole series: the master and all its exceptions live in one &lt;code&gt;.ics&lt;/code&gt; file. When it changes, you have to compare its exceptions with the ones you saw last time to find out which were removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about calendars with no change feed?
&lt;/h2&gt;

&lt;p&gt;Not every API has any of this. Calendly and Zoom can't list meetings by when they last changed. The honest approach there is to read every meeting each time and compare it with what you saw last time. To keep that cheap, you store a short hash per event rather than the event itself, so your "cursor" is a list of ids and fingerprints that grows with the calendar. ICS feeds are the same, except the server can save you the download with &lt;code&gt;304 Not Modified&lt;/code&gt; if you send its &lt;code&gt;ETag&lt;/code&gt; back. Cal.com sits in between: it can list bookings sorted by when they were updated, so you can read from the last change you saw (with some overlap, because a slow write can land a little out of order).&lt;/p&gt;

&lt;p&gt;These cursors never expire, since there's no server state behind them. They're just a record of what you saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  How calsdk handles it
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://www.cal-sdk.com/docs" rel="noopener noreferrer"&gt;calsdk&lt;/a&gt; partly because I got tired of writing this four times. &lt;code&gt;syncEvents&lt;/code&gt; wraps all of the above behind one call: you apply the changes, save &lt;code&gt;nextCursor&lt;/code&gt;, and catch &lt;code&gt;SyncCursorExpiredError&lt;/code&gt; to start over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SyncCursorExpiredError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;syncEvents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cal-sdk/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calendarId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;syncCursors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calendarId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nextCursor&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncEvents&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;calendarId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;change&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upsert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;syncCursors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calendarId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nextCursor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SyncCursorExpiredError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calendarId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;syncCursors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calendarId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calendarId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood it's exactly the approaches above. Google uses sync tokens over the whole calendar, and Outlook uses a delta link over a window (a year back and two years ahead by default, which you can change with &lt;code&gt;providerOptions.microsoft.timeMin&lt;/code&gt; and &lt;code&gt;timeMax&lt;/code&gt; on the first sync). CalDAV uses &lt;code&gt;sync-collection&lt;/code&gt; and doesn't fall back to CTags, so a server without RFC 6578 won't sync. Calendly, Zoom and ICS compare fingerprints. Every provider returns the same thing: series, exceptions and single events, never computed occurrences. The cursor is an opaque string, so you store it and never parse it.&lt;/p&gt;

&lt;p&gt;Save the cursor only after the changes are applied. If your process dies in between, the next run replays them instead of losing them.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.cal-sdk.com/docs/guides/sync" rel="noopener noreferrer"&gt;sync guide&lt;/a&gt; covers paging through a large first sync and how much each provider reads per round. If you want to see the API without connecting a real account, the &lt;a href="https://www.cal-sdk.com/docs/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; runs against an in-memory calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happens when a Google Calendar sync token expires?
&lt;/h3&gt;

&lt;p&gt;Google answers &lt;code&gt;410 Gone&lt;/code&gt;. Delete your copy of the calendar and run a full sync again. There's no way to resume from where you were.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use timeMin with a Google sync token?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;timeMin&lt;/code&gt;, &lt;code&gt;timeMax&lt;/code&gt;, &lt;code&gt;updatedMin&lt;/code&gt;, &lt;code&gt;q&lt;/code&gt;, &lt;code&gt;orderBy&lt;/code&gt;, &lt;code&gt;iCalUID&lt;/code&gt; and the extended property filters can't be combined with &lt;code&gt;syncToken&lt;/code&gt;, so the first sync has to read the whole calendar.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does Microsoft Graph report deleted events in a delta query?
&lt;/h3&gt;

&lt;p&gt;As stubs with an &lt;code&gt;@removed&lt;/code&gt; annotation. The same stub also marks events outside the window that changed, so an event moved out of your window looks like a deletion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does iCloud support incremental sync?
&lt;/h3&gt;

&lt;p&gt;Yes. iCloud is a CalDAV server, and CalDAV's &lt;code&gt;sync-collection&lt;/code&gt; REPORT (RFC 6578) returns the resources that changed or were removed since your last sync token.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should I save the sync cursor?
&lt;/h3&gt;

&lt;p&gt;Only after the changes are applied to your copy. If your process dies in between, the next run replays the changes instead of losing them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cal-sdk.com/blog/incremental-calendar-sync" rel="noopener noreferrer"&gt;cal-sdk.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>Finding a free slot across Google, Outlook and iCloud</title>
      <dc:creator>Vaibhav Pathak</dc:creator>
      <pubDate>Thu, 05 Nov 2026 19:42:27 +0000</pubDate>
      <link>https://dev.to/vkpdeveloper/finding-a-free-slot-across-google-outlook-and-icloud-2n7n</link>
      <guid>https://dev.to/vkpdeveloper/finding-a-free-slot-across-google-outlook-and-icloud-2n7n</guid>
      <description>&lt;p&gt;Here's a feature request that sounds like an afternoon's work: show the times when Ana (on Google Calendar) and Raj (on Outlook) are both free for 30 minutes next week, during Berlin working hours.&lt;/p&gt;

&lt;p&gt;You fetch two lists of busy times, merge them and look for the gaps. The merge really is the easy part. The hard part is getting a busy time you can trust from each provider, because the three big calendar APIs each answer "when is this person busy?" in a different way, and one of them barely answers it at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Google Calendar report busy time?
&lt;/h2&gt;

&lt;p&gt;Google is the easy one. You &lt;code&gt;POST /freeBusy&lt;/code&gt; with a window and a list of calendar ids, and get back busy ranges per calendar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"calendars"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"busy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-09T08:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-09T09:00:00Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"team@example.com"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"global"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"notFound"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two things to notice. First, Google has already applied its own rules: an event marked "show as free" (&lt;code&gt;transparency: "transparent"&lt;/code&gt;) isn't in there, and it works for calendars the user can only see as free/busy, where listing events would give you nothing. Second, a failure doesn't fail the request. A calendar that wasn't found, or that isn't shared with you, comes back with an &lt;code&gt;errors&lt;/code&gt; array and no &lt;code&gt;busy&lt;/code&gt; key. If your code reads &lt;code&gt;busy ?? []&lt;/code&gt;, that calendar now looks completely free. That isn't an edge case. It's the default way to get it wrong.&lt;/p&gt;

&lt;p&gt;The only limit worth knowing is that one request covers at most 50 calendars, so a big team needs batching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not use Outlook's getSchedule?
&lt;/h2&gt;

&lt;p&gt;Microsoft Graph does have a free/busy endpoint, &lt;code&gt;getSchedule&lt;/code&gt;. You give it email addresses and a window, and it gives you back an &lt;code&gt;availabilityView&lt;/code&gt; string with one digit per time slot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scheduleId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"raj@contoso.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"availabilityView"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"000220130"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0&lt;/code&gt; is free, &lt;code&gt;1&lt;/code&gt; tentative, &lt;code&gt;2&lt;/code&gt; busy, &lt;code&gt;3&lt;/code&gt; out of office and &lt;code&gt;4&lt;/code&gt; working elsewhere. The slot length (&lt;code&gt;availabilityViewInterval&lt;/code&gt;) defaults to 30 minutes and goes from 5 to 1440. There's also a &lt;code&gt;scheduleItems&lt;/code&gt; array with the actual start and end of each item, which is what you want: the digit string rounds everything to the slot length.&lt;/p&gt;

&lt;p&gt;The catch is in the permissions table: &lt;code&gt;getSchedule&lt;/code&gt; doesn't support personal Microsoft accounts. If your users sign in with an outlook.com or hotmail.com address, it's not an option. On a busy shared calendar it also gives up past 1000 entries in the window.&lt;/p&gt;

&lt;p&gt;The other route works for every account: list the events with &lt;code&gt;calendarView&lt;/code&gt;, which expands recurring series into occurrences on the server, and work out busy time yourself. That's what calsdk does for Outlook. It does mean you now own the rules Graph would have applied for you, starting with &lt;code&gt;showAs&lt;/code&gt;. Outlook has six values (&lt;code&gt;free&lt;/code&gt;, &lt;code&gt;tentative&lt;/code&gt;, &lt;code&gt;busy&lt;/code&gt;, &lt;code&gt;oof&lt;/code&gt;, &lt;code&gt;workingElsewhere&lt;/code&gt; and &lt;code&gt;unknown&lt;/code&gt;), and you have to decide which ones block time.&lt;/p&gt;

&lt;p&gt;Interestingly, &lt;code&gt;getSchedule&lt;/code&gt; writes "working elsewhere" as &lt;code&gt;0&lt;/code&gt;, which is free. I went the other way and count everything except &lt;code&gt;free&lt;/code&gt; as busy. Someone working from another office, or tentatively holding a slot, usually doesn't want a stranger booking it.&lt;/p&gt;

&lt;p&gt;Time zones need care too. Graph names zones the Windows way, like &lt;code&gt;"Pacific Standard Time"&lt;/code&gt;, not &lt;code&gt;"America/Los_Angeles"&lt;/code&gt;, and a &lt;code&gt;Prefer: outlook.timezone&lt;/code&gt; header changes which zone it writes times in. For busy time, ask for UTC with &lt;code&gt;Prefer: outlook.timezone="UTC"&lt;/code&gt; and you skip the mapping entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you get busy time from iCloud and CalDAV?
&lt;/h2&gt;

&lt;p&gt;CalDAV is the protocol behind iCloud, Fastmail, Nextcloud, Zoho and most self-hosted calendars. On paper it has free/busy too: RFC 4791 defines a &lt;code&gt;free-busy-query&lt;/code&gt; REPORT. In practice I didn't want a feature where whether it works depends on which server the user happens to be on, so calsdk treats CalDAV the same way as Outlook: list the events and compute.&lt;/p&gt;

&lt;p&gt;Listing is a &lt;code&gt;calendar-query&lt;/code&gt; REPORT with a time range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;C:calendar-query&lt;/span&gt; &lt;span class="na"&gt;xmlns:D=&lt;/span&gt;&lt;span class="s"&gt;"DAV:"&lt;/span&gt; &lt;span class="na"&gt;xmlns:C=&lt;/span&gt;&lt;span class="s"&gt;"urn:ietf:params:xml:ns:caldav"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;D:prop&amp;gt;&amp;lt;D:getetag/&amp;gt;&amp;lt;C:calendar-data/&amp;gt;&amp;lt;/D:prop&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;C:filter&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;C:comp-filter&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"VCALENDAR"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;C:comp-filter&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"VEVENT"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;C:time-range&lt;/span&gt; &lt;span class="na"&gt;start=&lt;/span&gt;&lt;span class="s"&gt;"20261108T000000Z"&lt;/span&gt; &lt;span class="na"&gt;end=&lt;/span&gt;&lt;span class="s"&gt;"20261115T000000Z"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/C:comp-filter&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/C:comp-filter&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/C:filter&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/C:calendar-query&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What comes back is raw iCalendar text, and everything is now your problem. Recurring events arrive as one &lt;code&gt;VEVENT&lt;/code&gt; with an &lt;code&gt;RRULE&lt;/code&gt; and its exceptions, so you expand the occurrences yourself. Servers read floating times and all-day dates in a zone of their choosing, so calsdk asks for a day of slack on either side and filters the results itself. Busy or free is the &lt;code&gt;TRANSP&lt;/code&gt; property (&lt;code&gt;TRANSPARENT&lt;/code&gt; means free), and cancelled occurrences have &lt;code&gt;STATUS:CANCELLED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Some servers have extra quirks. Zoho answers a calendar query without the event data, so every event has to be fetched again with a &lt;code&gt;calendar-multiget&lt;/code&gt;. If one of those fetches fails, it's tempting to skip that event. Don't: a missing event is time that looks free. calsdk fails the whole listing instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What catches people out on every provider?
&lt;/h2&gt;

&lt;p&gt;All-day events are dates, not instants. A holiday on November 9 means November 9 in the calendar's own time zone, so to turn it into a busy range you need that zone, which you only get from the calendar list. If you can't get it, the safe choice is to block the day in every zone, from UTC+14 through UTC-12. That covers more than a day, but it never shows a holiday as free.&lt;/p&gt;

&lt;p&gt;Private events still block time. You won't see the title, but you need the range.&lt;/p&gt;

&lt;p&gt;Declined invitations are a judgment call. The event fallback in calsdk only looks at the event's own free/busy flag and whether it's cancelled, not at your RSVP. So a declined invite that stays on the calendar still counts as busy. That's a choice, and it errs the same way as everything else: better to hide a free slot than to offer a taken one.&lt;/p&gt;

&lt;p&gt;Rate limits are real once you list a week of events for 20 people. Retry &lt;code&gt;429&lt;/code&gt;s after the &lt;code&gt;Retry-After&lt;/code&gt; the provider sends, and keep the window as small as the feature allows.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you merge busy times without double booking?
&lt;/h2&gt;

&lt;p&gt;With trustworthy busy ranges in hand, the algorithm is short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put every busy range from every calendar into one list, clip it to the window, sort by start and merge ranges that overlap or touch.&lt;/li&gt;
&lt;li&gt;Walk candidate start times (every 15 minutes, say) inside working hours, in the organizer's time zone.&lt;/li&gt;
&lt;li&gt;Keep a candidate if &lt;code&gt;[start, start + duration)&lt;/code&gt; doesn't overlap any merged range.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Working hours need more care than they seem to. "9 to 5 in Berlin" is a different UTC range on either side of a daylight saving change, and on the day the clocks spring forward an hour of wall-clock time simply doesn't exist. calsdk places slots on the local wall clock within each stretch of constant UTC offset, so the grid stays on the hour after the change and the missing hour gets no slots.&lt;/p&gt;

&lt;p&gt;The important decision is what to do when one calendar can't be read. Remember Google's per-calendar &lt;code&gt;errors&lt;/code&gt;. If Raj's calendar comes back with an error and you carry on with Ana's alone, every slot you show is a guess about Raj. A booking page that confidently offers a time that clashes is worse than one that says "couldn't check Raj's calendar". So &lt;code&gt;findFreeTime&lt;/code&gt; fails closed: it throws &lt;code&gt;FreeBusyUnavailableError&lt;/code&gt;, with the &lt;code&gt;provider&lt;/code&gt; and &lt;code&gt;calendarId&lt;/code&gt;, rather than return slots that might clash.&lt;/p&gt;

&lt;h2&gt;
  
  
  How calsdk does it
&lt;/h2&gt;

&lt;p&gt;calsdk wraps all of the above in two functions. &lt;code&gt;getFreeBusy&lt;/code&gt; gives you merged busy ranges for one provider. It uses Google's endpoint where it exists, and lists events everywhere else, returning a &lt;code&gt;compatibility&lt;/code&gt; warning so you know which path it took. &lt;code&gt;findFreeTime&lt;/code&gt; takes calendars from any mix of providers and gives you the slots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;findFreeTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FreeBusyUnavailableError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cal-sdk/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;icloud&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cal-sdk/caldav&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cal-sdk/google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;microsoft&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cal-sdk/microsoft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;warnings&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;findFreeTime&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;calendars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;google&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;anaToken&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;microsoft&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rajToken&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="na"&gt;calendarIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;roomCalendarId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;icloud&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;appleId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;appPassword&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;timeMin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-09T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeMax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-14T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;workingHours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;09:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;17:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// slots: [{ start: '2026-11-09T08:00:00.000Z', end: '2026-11-09T08:30:00.000Z' }, ...]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FreeBusyUnavailableError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Tell the user which calendar couldn't be read, instead of guessing.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slots come back as UTC instants, ready to show in each viewer's own zone. The &lt;a href="https://www.cal-sdk.com/docs/guides/free-time" rel="noopener noreferrer"&gt;free time guide&lt;/a&gt; covers slot intervals, overlapping slots and booking the one the user picks.&lt;/p&gt;

&lt;p&gt;If you want to poke at it without connecting a real account, the &lt;a href="https://www.cal-sdk.com/docs/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; runs against an in-memory calendar in a couple of minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Outlook have a free/busy API?
&lt;/h3&gt;

&lt;p&gt;Yes: Microsoft Graph's &lt;code&gt;getSchedule&lt;/code&gt;. It doesn't support personal Microsoft accounts (outlook.com and hotmail.com), and it gives up past 1000 entries in the window. Listing events with &lt;code&gt;calendarView&lt;/code&gt; and computing busy time yourself works for every account.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does iCloud support free/busy queries?
&lt;/h3&gt;

&lt;p&gt;iCloud speaks CalDAV, and RFC 4791 defines a &lt;code&gt;free-busy-query&lt;/code&gt; REPORT, but support varies between CalDAV servers. Listing events with a &lt;code&gt;calendar-query&lt;/code&gt; REPORT and computing busy time yourself works the same on every server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should tentative and declined events count as busy?
&lt;/h3&gt;

&lt;p&gt;Count everything except events marked "show as free" as busy, including tentative ones. calsdk also treats a declined invite that is still on the calendar as busy, because offering a taken slot is worse than hiding a free one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should happen when one calendar can't be read?
&lt;/h3&gt;

&lt;p&gt;Fail and say which calendar it was. Slots computed without that calendar are guesses that can clash. calsdk's &lt;code&gt;findFreeTime&lt;/code&gt; throws &lt;code&gt;FreeBusyUnavailableError&lt;/code&gt; with the provider and calendar id.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many calendars can one Google freeBusy request check?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Batch the request for larger teams.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cal-sdk.com/blog/finding-free-time-across-calendars" rel="noopener noreferrer"&gt;cal-sdk.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>One standup, three calendar APIs</title>
      <dc:creator>Vaibhav Pathak</dc:creator>
      <pubDate>Thu, 05 Nov 2026 19:42:02 +0000</pubDate>
      <link>https://dev.to/vkpdeveloper/one-standup-three-calendar-apis-38o5</link>
      <guid>https://dev.to/vkpdeveloper/one-standup-three-calendar-apis-38o5</guid>
      <description>&lt;p&gt;A daily standup is the most boring event on any calendar. It's also where calendar APIs disagree the most.&lt;/p&gt;

&lt;p&gt;Here's the event I'll use for the rest of this post: a standup at 9:30 in Berlin, every weekday, ten times, starting Monday, November 9. Someone cancels Tuesday's. Someone else moves Friday's to 10:00. Any calendar UI can show that in a second, but each of the three big APIs stores it in its own way, and an app that supports all three has to read and write every version.&lt;/p&gt;

&lt;p&gt;I've spent a lot of time with these differences while building &lt;a href="https://www.cal-sdk.com/docs" rel="noopener noreferrer"&gt;calsdk&lt;/a&gt;, so here is how each provider stores that week, and the places where it caught me out.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Google Calendar store a recurring event?
&lt;/h2&gt;

&lt;p&gt;Google is the closest to the standard. The series is one event with a &lt;code&gt;recurrence&lt;/code&gt; array of RFC 5545 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Standup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dateTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-09T09:30:00+01:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Europe/Berlin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dateTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-09T09:45:00+01:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Europe/Berlin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recurrence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=10"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The changes are separate objects. Friday's move is an instance with its own id (the series id plus the original start in UTC), a &lt;code&gt;recurringEventId&lt;/code&gt; pointing back at the series, and an &lt;code&gt;originalStartTime&lt;/code&gt; that says which slot it replaced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123_20261113T083000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recurringEventId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"originalStartTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dateTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-13T09:30:00+01:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Europe/Berlin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dateTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-13T10:00:00+01:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Europe/Berlin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tuesday's cancellation is the first surprise. You might expect an &lt;code&gt;EXDATE&lt;/code&gt; line in the series' &lt;code&gt;recurrence&lt;/code&gt;, and Google does accept those. But deleting an instance through the API leaves a third kind of object: an instance with &lt;code&gt;status: "cancelled"&lt;/code&gt;. So there are two ways to say "not on Tuesday", and your code has to handle both.&lt;/p&gt;

&lt;p&gt;For reading, &lt;code&gt;singleEvents=true&lt;/code&gt; expands the series on Google's side and gives you one object per occurrence. That's convenient, but the expanded list doesn't tell you which occurrences were edited. Friday looks like every other day, just at 10:00. If your UI wants to say "this occurrence was changed", you need a second request with &lt;code&gt;singleEvents=false&lt;/code&gt; to find the exceptions.&lt;/p&gt;

&lt;p&gt;The hard part is "this and following events". Google Calendar's UI offers it, but the API has no such operation. You build it yourself: end the old series just before the chosen occurrence, and create a new series starting there. If the rule has a &lt;code&gt;COUNT&lt;/code&gt;, you split the count between the two halves. Then you learn that Google resets edited occurrences when a series moves or splits, so Friday's change to 10:00 is lost unless you carry it over.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Outlook store a recurring event?
&lt;/h2&gt;

&lt;p&gt;Microsoft Graph doesn't use RRULE at all. A series is a &lt;code&gt;seriesMaster&lt;/code&gt; with a &lt;code&gt;recurrence&lt;/code&gt; object made of a pattern and a range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AAMkAGI2..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"seriesMaster"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Standup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"originalStartTimeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"W. Europe Standard Time"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recurrence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"weekly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"interval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"daysOfWeek"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"monday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tuesday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wednesday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"thursday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"friday"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"firstDayOfWeek"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"monday"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"range"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"numbered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"startDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-09"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"numberOfOccurrences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"recurrenceTimeZone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"W. Europe Standard Time"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things stand out. First, time zones have Windows names: Berlin is &lt;code&gt;W. Europe Standard Time&lt;/code&gt;, and New York is &lt;code&gt;Eastern Standard Time&lt;/code&gt;. So the first thing you need is a mapping table to IANA names, and calsdk ships one generated from the CLDR data.&lt;/p&gt;

&lt;p&gt;Second, a pattern can't express everything an RRULE can. Daily, weekly on set days, and monthly or yearly on a date or an "nth weekday" all map cleanly. Hourly rules, &lt;code&gt;RDATE&lt;/code&gt;, and most combinations of &lt;code&gt;BYDAY&lt;/code&gt; with &lt;code&gt;BYMONTHDAY&lt;/code&gt; have no pattern. Some rules map but behave differently. &lt;code&gt;RRULE:FREQ=MONTHLY;BYMONTHDAY=31&lt;/code&gt; skips months without a 31st, while Outlook moves that occurrence to the last day of the month. If you translate it naively, the user gets a meeting on February 28 that they never asked for. The RRULE that matches Outlook's behavior is &lt;code&gt;BYMONTHDAY=28,29,30,31;BYSETPOS=-1&lt;/code&gt;, and that's how calsdk reads it back.&lt;/p&gt;

&lt;p&gt;Instances have a &lt;code&gt;type&lt;/code&gt; of &lt;code&gt;occurrence&lt;/code&gt; or &lt;code&gt;exception&lt;/code&gt; and a &lt;code&gt;seriesMasterId&lt;/code&gt;. Cancellations are where Outlook surprised me most: there's no exclusion list. Outlook only remembers occurrences that were deleted one by one, as &lt;code&gt;cancelledOccurrences&lt;/code&gt; on the master. Graph only returns that when you &lt;code&gt;$select&lt;/code&gt; it on a single event by id, so you need one extra request per series to learn which days are gone. And because no list exists, you can't exclude a date by writing an &lt;code&gt;EXDATE&lt;/code&gt;. You delete that occurrence instead.&lt;/p&gt;

&lt;p&gt;There's no "this and following" here either. You end the series and create a new one, with the same bookkeeping as on Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do iCloud and CalDAV store a recurring event?
&lt;/h2&gt;

&lt;p&gt;iCloud, Fastmail, Nextcloud and Zoho all speak CalDAV. CalDAV stores calendars as iCalendar files on a server, so the whole series, with every exception, is one &lt;code&gt;.ics&lt;/code&gt; resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEGIN:VEVENT
UID:standup-7f3a
DTSTART;TZID=Europe/Berlin:20261109T093000
DTEND;TZID=Europe/Berlin:20261109T094500
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=10
EXDATE;TZID=Europe/Berlin:20261110T093000
SUMMARY:Standup
END:VEVENT
BEGIN:VEVENT
UID:standup-7f3a
RECURRENCE-ID;TZID=Europe/Berlin:20261113T093000
DTSTART;TZID=Europe/Berlin:20261113T100000
DTEND;TZID=Europe/Berlin:20261113T101500
SUMMARY:Standup
END:VEVENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(I've left out the &lt;code&gt;VCALENDAR&lt;/code&gt; wrapper and the &lt;code&gt;VTIMEZONE&lt;/code&gt; block.) Tuesday is an &lt;code&gt;EXDATE&lt;/code&gt;. Friday is a second &lt;code&gt;VEVENT&lt;/code&gt; with the same &lt;code&gt;UID&lt;/code&gt; and a &lt;code&gt;RECURRENCE-ID&lt;/code&gt; naming the slot it replaces. It's the most honest of the three formats, and the most work to use.&lt;/p&gt;

&lt;p&gt;There are no instance ids at all. If your app needs to point at "Friday's standup", you have to make up an id. calsdk uses the resource name plus the original start in UTC, such as &lt;code&gt;standup-7f3a_20261113T083000Z&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Editing one occurrence means rewriting the whole file. You read it, change or add a &lt;code&gt;VEVENT&lt;/code&gt;, and &lt;code&gt;PUT&lt;/code&gt; it back with &lt;code&gt;If-Match&lt;/code&gt; on the ETag, so you don't overwrite someone else's change made in the meantime.&lt;/p&gt;

&lt;p&gt;Then you expand the series yourself. CalDAV does let you ask the server to expand a series, but calsdk asks the server only for the resources in a time window, and expands them locally with ical.js. That gives one code path for every CalDAV server, and the same code reads plain ICS feeds, which have no server to ask. Expanding locally has its own traps. ical.js's own event expansion reads any &lt;code&gt;TZID&lt;/code&gt; it has no definition for as UTC, so an &lt;code&gt;EXDATE&lt;/code&gt; or &lt;code&gt;UNTIL&lt;/code&gt; compared with a start in New York can be hours off and fail to match. calsdk builds the set of occurrences itself to avoid that. A rule like &lt;code&gt;FREQ=SECONDLY&lt;/code&gt; from 2009 would expand forever, so expansion is capped, and those rules list only their first instance with a warning.&lt;/p&gt;

&lt;p&gt;Servers vary in small ways too. Zoho, for example, answers a calendar query without the event data, so every event has to be fetched again.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you put one API over all three?
&lt;/h2&gt;

&lt;p&gt;The design choice that made the rest possible was to speak RFC 5545 everywhere. Google and CalDAV already use it, so only Outlook needs translating, in both directions. Every event has a &lt;code&gt;kind&lt;/code&gt;: &lt;code&gt;single&lt;/code&gt;, &lt;code&gt;series&lt;/code&gt;, &lt;code&gt;occurrence&lt;/code&gt; or &lt;code&gt;exception&lt;/code&gt;. Occurrences and exceptions carry a &lt;code&gt;seriesId&lt;/code&gt; and an &lt;code&gt;originalStart&lt;/code&gt;, which stays put when the occurrence moves. Here's the standup week, which works the same with &lt;code&gt;microsoft()&lt;/code&gt; or &lt;code&gt;icloud()&lt;/code&gt; in place of &lt;code&gt;google()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deleteEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listEvents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updateEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cal-sdk/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cal-sdk/google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;calendar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;google&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getFreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Standup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-09T09:30:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-09T09:45:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;recurrence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listEvents&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeMin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-09T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeMax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-14T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Tuesday is cancelled, Friday moves to 10:00.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deleteEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-13T10:00:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dateTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-13T10:15:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updateEvent&lt;/code&gt; and &lt;code&gt;deleteEvent&lt;/code&gt; take a &lt;code&gt;scope&lt;/code&gt; for instances: &lt;code&gt;'this'&lt;/code&gt; (the default), &lt;code&gt;'all'&lt;/code&gt;, or &lt;code&gt;'thisAndFollowing'&lt;/code&gt;. On Google, calsdk does the split for &lt;code&gt;thisAndFollowing&lt;/code&gt; itself, and if the second write fails, it deletes the new series so you aren't left with both. Outlook and CalDAV throw &lt;code&gt;UnsupportedFunctionalityError&lt;/code&gt; for that scope, and &lt;code&gt;calendar.capabilities.events.recurrenceScopeThisAndFollowing&lt;/code&gt; tells you beforehand, so your UI can hide the option.&lt;/p&gt;

&lt;p&gt;The rule I held to is to never save a schedule that's different from the one you asked for. If Outlook can't store a rule exactly, calsdk throws instead of saving something close. If the call can still do what you meant, but something is lost along the way, such as Google resetting edited occurrences when a series moves, the call succeeds and returns a warning that says so. A standup that quietly lands on the wrong days is worse than an error.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.cal-sdk.com/docs/guides/recurring-events" rel="noopener noreferrer"&gt;recurring events guide&lt;/a&gt; has every scope and the limits for each provider. If you want to see it work without connecting an account, the &lt;a href="https://www.cal-sdk.com/docs/quickstart" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; runs against an in-memory calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How does Google Calendar mark a cancelled occurrence?
&lt;/h3&gt;

&lt;p&gt;In one of two ways: an &lt;code&gt;EXDATE&lt;/code&gt; line in the series' &lt;code&gt;recurrence&lt;/code&gt;, or, when the occurrence was deleted through the API, an instance with &lt;code&gt;status: "cancelled"&lt;/code&gt;. Code that reads Google events has to handle both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Microsoft Graph support RRULE?
&lt;/h3&gt;

&lt;p&gt;No. Graph stores a recurring event as a pattern and a range, with Windows time zone names. Most RRULEs translate, but hourly rules, &lt;code&gt;RDATE&lt;/code&gt; and some &lt;code&gt;BYDAY&lt;/code&gt; and &lt;code&gt;BYMONTHDAY&lt;/code&gt; combinations have no Outlook pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I find the deleted occurrences of an Outlook recurring event?
&lt;/h3&gt;

&lt;p&gt;Read &lt;code&gt;cancelledOccurrences&lt;/code&gt; on the series master. Graph only returns it when you &lt;code&gt;$select&lt;/code&gt; it on a single event by id, so it costs one request per series.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do the calendar APIs support "this and following events"?
&lt;/h3&gt;

&lt;p&gt;Google, Outlook and CalDAV don't have that operation. You end the old series before the chosen occurrence and create a new series from it. calsdk does that split for you on Google.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you edit one occurrence on CalDAV?
&lt;/h3&gt;

&lt;p&gt;Rewrite the series' &lt;code&gt;.ics&lt;/code&gt; file: add or change a &lt;code&gt;VEVENT&lt;/code&gt; with a &lt;code&gt;RECURRENCE-ID&lt;/code&gt; for that occurrence, then &lt;code&gt;PUT&lt;/code&gt; the file back with &lt;code&gt;If-Match&lt;/code&gt; on its ETag.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.cal-sdk.com/blog/recurring-events-across-calendar-apis" rel="noopener noreferrer"&gt;cal-sdk.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>api</category>
    </item>
  </channel>
</rss>
