Users expect mail to feel live. They also expect you not to burn their OAuth quota on full inbox pulls every time they blink. Real-time mail is a layering problem: push notifications tell you something changed; incremental sync tells you what; SSE tells the browser to refresh the merge — not replace the entire list from scratch.
Kvika bridges Gmail Pub/Sub and Microsoft Graph subscriptions through one Server-Sent Events endpoint. The architecture works on a single Node process today. Scaling it taught us where the seams are.
Three layers of freshness
Layer 1 — push. Gmail sends Pub/Sub messages with emailAddress and historyId. Outlook POSTs change notifications to your webhook URL. Both mean "invalidate cache and notify clients" — not "fetch every message body now."
Layer 2 — incremental fetch. On notification (or 30-second poll as backstop), run Gmail history.list or Outlook delta with stored cursors. Merge into the in-memory list via Map upsert/delete.
Layer 3 — SSE to browser. Connected clients receive {"type":"new-email","provider":"gmail"} and trigger an incremental pass — not forceRefresh on every ping.
One SSE endpoint for both providers
Browsers connect to /api/mail/events. We register each connection in global.mailClients — a Map from clientId to an enqueue function writing to a TransformStream:
global.mailClients.set(clientId, {
controller: { enqueue: (data) => writer.write(encoder.encode(data)) },
userId: session.user.email,
})
// Keep-alive every 30s; clean up on abort
writer.write(': keep-alive\n\n')Headers matter behind nginx: Cache-Control: no-cache, X-Accel-Buffering: no. Without them, events buffer until the connection closes and "real-time" becomes batch.
Gmail Pub/Sub watch
users.watch registers INBOX changes against a Cloud Pub/Sub topic. Google returns historyId and an expiration (~7 days). Renew on connect and via cron for active users.
The push handler decodes the Pub/Sub payload, maps email address to user, invalidates Redis emails:{userId}:*, and notifies only SSE clients whose userId matches — targeted fanout.
Outlook webhooks
Subscription creation returns a validation token you must echo as plain text — JSON responses fail handshake. Notifications include clientState; verify it matches your secret prefix before processing.
We return HTTP 202 even on processing errors. Microsoft retries aggressively on 5xx; during partial outages that amplifies load. Log hard, alert on rates, rely on delta catch-up as backstop.
Known gap: subscriptions expire in roughly four hours and renewal is not fully wired to per-user DB storage yet. Polling remains the real sync mechanism for many Outlook users until renewal ships.
Single-node limits
global.mailClients works on one Node instance. Horizontal scaling drops events unless webhooks publish to Redis pub/sub and each SSE node subscribes. Outlook webhooks currently broadcast to all connected clients — Gmail filters by email address; Outlook does not yet map subscriptionId → userId.
Document these limits before scaling. Real-time mail is not free — it is push + incremental + debounce, not push + full fetch.
Related: unified inbox sync. Join the Kvika beta.