Skip to content

Real-Time Google Calendar Sync on macOS

How hora uses Google Calendar watch channels, a Cloudflare Worker, APNs, and fallback polling to keep its native Mac calendar fresh and reliable.

10 min read
A Google Calendar change signal traveling through a webhook and APNs before hora refreshes the affected Mac calendar

The first sync loop in hora was deliberately boring. Every five minutes, fetch changes from Google Calendar and update the local cache.

It was easy to understand and hard to trust.

If somebody moved a meeting in the browser, the menu bar could keep showing the old time until the next poll. If I answered an invitation on my phone, the Mac app could keep the old response state. Nothing was permanently broken, but the dedicated calendar was temporarily less correct than the browser tab it was supposed to replace.

That is why I added a faster path:

Google Calendar -> HTTPS webhook -> Cloudflare Worker -> APNs -> hora -> Google Calendar API

I still call it real-time sync because changes normally trigger work instead of waiting for a timer. But it is not a real-time guarantee. Google says Calendar watch notifications are not 100 percent reliable, and Apple treats background notifications as low priority and does not guarantee delivery.

The production design therefore uses push as an invalidation signal and polling as reconciliation. That distinction is the whole architecture.

The short answer: Google push does not contain the changed event

Google Calendar supports events.watch, which creates a notification channel for one calendar's event collection.

When something changes, Google sends an HTTPS POST to the registered webhook. The request contains X-Goog-* headers such as the channel ID, resource ID, resource state, and message number.

It does not contain the changed event.

Google's push notification guide explicitly says Calendar notification messages have no body and require another API call to retrieve the details.

That makes the webhook a doorbell, not a delivery truck. It tells hora that a calendar may be stale. The Mac still fetches event data directly from Google with the user's OAuth session.

The pipeline in hora

The current flow has six steps.

1. The Mac registers with APNs

After the first app frame is visible, hora calls registerForRemoteNotifications(). AppKit returns a device token through NSApplicationDelegate.

hora uploads this routing information to the Worker:

let body: [String: Any] = [
    "userId": account,
    "apnsToken": token,
    "apnsEnv": environment,
    "bundleId": bundleIdentifier,
]

The environment and bundle identifier matter because development and App Store builds use different APNs routing contexts.

2. hora watches each active Google calendar

For each calendar the user keeps visible and enabled for events, the app creates a unique events.watch channel.

The request includes:

  • a fresh UUID for the channel
  • the public HTTPS webhook address
  • a channel token used to validate the incoming notification
  • a requested TTL of 604,800 seconds, which is seven days

Google returns a resource ID and an expiration timestamp. hora stores the channel record in UserDefaults because this is short-lived device bookkeeping, not calendar content.

The events.watch reference lists seven days as the default TTL. The broader push guide notes that Google's own limits can shorten a requested expiration, so hora trusts the returned timestamp rather than assuming the request was accepted exactly.

3. Google calls the Worker

When the watched event collection changes, Google sends a headers-only POST to the Cloudflare Worker.

The Worker validates and maps the channel, then sends an APNs background notification to the registered app instance. The payload is intentionally small:

{
  "aps": { "content-available": 1 },
  "hora": {
    "calendarId": "calendar-id",
    "channelId": "channel-id"
  }
}

There is no event title, attendee list, description, or meeting note in this relay. Google did not send that content to the webhook, and the Mac retrieves it directly from the Calendar API after the signal arrives.

Cloudflare Workers fit this job because the runtime's fetch handler can receive an external HTTP request and return a response without a permanently running application server.

4. APNs wakes the sync path

Apple calls this a background notification. Many developers call it a silent push because it has no alert, badge, or sound.

Apple's background update documentation describes content-available: 1 and is also clear about the limitation: delivery can be delayed, coalesced, throttled, or dropped.

When hora receives the payload, it records the push for watchdog health, identifies the affected calendar, and schedules a sync. A two-second debounce coalesces several notifications that arrive close together.

5. The app fetches from Google

The original implementation went straight to incremental sync using the last syncToken. That is the normal design described in Google's incremental synchronization guide: perform a full seed, store nextSyncToken, then request only changes on later passes.

hora still uses sync tokens for ordinary synchronization. The pushed path has one extra safety rule.

After I found recurring instance edits that could leave the local grid stale, the APNs handler started clearing the token for the calendar named in the push:

if let calendarID {
    SyncTokenStore.setToken(nil, for: calendarID)
}

Task { @MainActor in
    await PushChannelService.shared.triggerImmediateSyncFromPush()
}

On the next sync pass, that affected calendar receives a fresh fetch for hora's active 90-day window, from 30 days ago to 60 days ahead. Other calendars can still use their incremental tokens.

This costs more than a perfect one-event delta. It is also a deliberate correctness tradeoff. A push that arrives quickly but leaves one exception from a recurring series stale is not a successful real-time sync.

6. The local model and system surfaces update

Network results are processed in a background SwiftData context and then made visible to the main context. The same completed sync also feeds notifications, the menu bar model, widgets, and other cached surfaces.

The push itself never edits local events. It only starts the reconciliation path.

The fast push path, the direct Google API fetch, and the polling safety loop used by hora on macOS.

Why polling still exists

Two separate providers can drop or delay the signal:

  • Google says a small percentage of Calendar notifications can be dropped under normal conditions.
  • Apple says background notifications are low priority and not guaranteed.

Removing polling would turn an optimization into a single point of failure.

hora therefore changes the timer based on channel health:

private var syncInterval: TimeInterval {
    let configured = max(1, userSetting ?? 5)
    let effective = pushActive ? max(configured, 30) : configured
    return TimeInterval(effective) * 60
}

When at least one healthy watch channel is active, periodic polling relaxes to no more frequently than every 30 minutes. If push setup is disabled or the service degrades, the normal configured interval takes over again.

This means push improves freshness and reduces unnecessary polling, but the timer remains the repair mechanism for a missed signal.

Expiring channels are normal, not an error

Google does not renew notification channels automatically. The app must create a new channel with a new ID before the old one expires.

hora stores:

struct PushChannelRecord: Codable {
    var channelId: String
    var accountEmail: String
    var calendarId: String
    var resourceId: String
    var expirationMs: Int64
}

The service checks returned expirations and rotates a channel when less than 24 hours remain. It makes a best-effort channels.stop call for the old channel and unregisters the mapping from the Worker.

The order matters at logout too. hora stops the user's Google channels before deleting the OAuth session. If the token disappears first, the app no longer has the authorization context required to stop channels created by that account.

Expired or already removed channels can return 404 or 410. The current cleanup path treats those as steady-state outcomes rather than user-facing failures.

The race that changed my retry policy

A watch request can fail from the client's point of view after Google has already created the channel. A timeout or 5xx does not prove the server did nothing.

Blindly retrying the same ID can then produce "Channel id not unique." Retrying with new IDs can leak several live channels that all notify the same webhook.

The current implementation avoids automatic transient retries inside the one watch request. It records a pending channel ID before sending, then lets the channel service perform a later attempt with fresh bookkeeping and cooldown rules.

This is the kind of failure mode that did not appear in the happy-path diagram. It became much more important once real users had multiple accounts and calendars.

Quiet calendars made the watchdog harder than expected

I added a watchdog to detect an account that had not received any push for a while. The first version was too eager.

A quiet holiday or read-only calendar is not evidence of a broken channel. Recreating healthy channels every time a calendar stays quiet creates registration churn and more ways to hit Google limits.

The current watchdog waits for consecutive silent windows, applies an exponential recovery backoff, and only tears down channels that are already near expiration. Repeated failures disable the push fast path so normal polling can keep the data fresh.

This is a better operational rule than "no notification means broken."

Some Google-managed calendars need a different path

During registration, hora encountered calendars that rejected events.watch with pushNotSupportedForRequestedResource. The app skips known Google-managed calendar suffixes for holidays, contacts, and weather, and caches new unsupported responses per account and calendar.

That is an implementation observation, not a claim that every subscribed or read-only calendar rejects push. The service attempts the supported path and remembers the calendars Google explicitly declines.

What real-time means in the product

I removed the old table claiming every change arrived in about 1.5 seconds. I did not have durable production evidence for a universal latency number, and neither Google nor Apple provides that guarantee.

The honest promise is simpler:

  • a calendar change normally creates a signal instead of waiting for the next five-minute poll
  • the Mac fetches event content directly from Google after that signal
  • repeated signals are debounced
  • missed signals are repaired by polling
  • an invalid sync token produces a fresh sync rather than a permanently stale cache

Fast is valuable. Correct after sleep, dropped notifications, expiration, logout, and recurring edits is more valuable.

What I would improve next

The pushed path currently starts an application-wide sync cycle across every configured account, even though the payload identifies one calendar. Most other calendars use cheap incremental requests, but the trigger could still become more targeted.

I would also like stronger end-to-end observability that separates these timings:

  1. Google change to webhook receipt
  2. webhook receipt to APNs acceptance
  3. APNs delivery to sync start
  4. Google fetch to visible local update

That would support a latency statement based on percentiles instead of one test on my desk.

The architecture described here is the sync path behind hora Calendar 1.0. If you live in Google Calendar and want a native Mac client with push-assisted sync, try hora Calendar on the Mac App Store.

Google Calendar push sync FAQ

06 questions / quick answers

Does Google Calendar support push notifications?

Yes. The Calendar API exposes watch methods for resources including Events. A client creates a notification channel with an HTTPS webhook, and Google sends a headers-only POST when the watched resource changes.

Does a Google Calendar push contain the changed event?

No. Calendar push messages contain X-Goog headers but no event body. The receiving application must make another authenticated Calendar API request to discover and fetch the changes.

What is a Google Calendar sync token?

A sync token represents the state after a completed list operation. The client stores nextSyncToken and sends it with a later request to retrieve changes and deletions. If Google returns 410 Gone, the token is invalid and the client must perform a new full sync.

Are APNs silent pushes guaranteed to arrive?

No. Apple calls them background notifications and may delay, coalesce, throttle, or drop them. They are useful as a refresh signal, but a reliable calendar client still needs periodic reconciliation.

Do Google Calendar watch channels renew automatically?

No. A channel has an expiration returned by Google. Before it expires, the client must create a replacement with a new channel ID and should stop the old channel when possible.

Does hora send event details through the Cloudflare Worker?

The Google webhook message has no event body, and hora's APNs payload carries routing identifiers such as the calendar and channel IDs. Event titles, descriptions, attendees, and meeting details are fetched by the Mac directly from Google after the signal arrives.

Related stories

Google Calendar API vs CalDAV: Which Should You Use?
Engineering

Google Calendar API vs CalDAV: Which Should You Use?

Google Calendar API or CalDAV? Compare sync, auth, event support, and portability, plus the tradeoffs I learned while building hora for Google Calendar.

Maciej Szamowski10 min read
Google Calendar API in Swift: The Package I Missed
Engineering

Google Calendar API in Swift: The Package I Missed

I hand-built a Google Calendar API client in Swift, then migrated hora to Google's GTLR library. See the code, tradeoffs, and rate-limit lessons.

Maciej Szamowski10 min read
How I Fixed SwiftUI Color Contrast with OKLCH and APCA
Engineering

How I Fixed SwiftUI Color Contrast with OKLCH and APCA

A TestFlight screenshot exposed unreadable calendar tiles. Here is how I rebuilt hora's SwiftUI color system with OKLCH, APCA-style checks, and tests.

Maciej Szamowski10 min read
Mobile Beta Sign-up

Want to stay updated?

Subscribe for information about iOS/iPadOS beta availability.