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.

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 APII 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.

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:
- Google change to webhook receipt
- webhook receipt to APNs acceptance
- APNs delivery to sync start
- 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


