Day 73 of 100·System Design Day·1245 words

Realtime — WebSockets vs SSE vs long polling, and scaling them

The transport taxonomy, plus why both your products chose polling and push notifications instead.

Concept → your projects

The realtime transport ladder:

  • Polling (fixed interval): simplest; latency = interval; wasted requests when nothing changes. With ETags/If-None-Match it's cheap (304s).
  • Long polling: server holds the request until data or timeout → near-realtime with HTTP semantics; each update costs a round trip; holds connections (thread/goroutine per request — fine in Go/Node async, costly elsewhere).
  • SSE (Server-Sent Events): one long-lived HTTP response streaming text events; server→client only; auto-reconnect built into the browser API; works through most proxies (it's just HTTP); the right default for one-way streams (dashboards, notifications, LLM token streaming).
  • WebSockets: full duplex over an upgraded HTTP connection; lowest latency both directions; needs ping/pong keepalives, reconnect logic, and — the hard part — stateful scaling: sticky routing, a pub/sub backplane (Redis) so a message published on node A reaches the socket held by node B, presence tracking, and graceful draining on deploys. Cost: the connection layer is now stateful infrastructure.

Scaling realtime (the interview meat): N sockets spread over M servers → a registry (Redis/etcd) mapping user → server; fanout via pub/sub or a message bus; backpressure per connection (a slow client must not stall the fanout — bounded per-connection queues + drop policy); reconnect storms after a deploy (jittered backoff, exponential); and horizontal scale by sharding connections consistently (Day 21's consistent hashing makes a return).

Where your systems land — and the defensible reasoning:

  • Traverse: no sockets. Stats/notifications are polled, and true push goes through APNs/FCM (the OS holds the persistent connection — you outsource the stateful realtime layer to Apple/Google, which is exactly what mobile apps should do). The notification inbox is fetched on screen focus; unread counts come from the same queries. The trade stated: polling costs a few requests per minute per active user; sockets would cost a stateful gateway, connection registry, and reconnect handling — for freshness nobody needs (a revision reminder is useful to the minute, not the millisecond). Push for urgency, polling for freshness: a complete architecture.
  • Zoth: approval decisions are human-paced; the dashboard polls. A realtime approval inbox would be the natural socket use case ("your request was approved" toast) — and the honest answer is: SSE (one-way, server→client) with a Redis pub/sub backplane would be the right first step, not WebSockets, because the client never pushes on that channel.

The rule to recite: "Do I need bidirectional? No → SSE. Do I need sub-second one-way? Yes → SSE. Do I need the OS to wake a closed app? Push notifications. Otherwise, polling with cache validators is the cheapest correct answer."

The decision tree, in order

Four questions, each of which can end the conversation early:

  1. Does the client need to send data on the same channel? If no — and for the overwhelming majority of product features it is no — WebSockets are disqualified, because you would be paying for duplex state you never use. Stop here for dashboards, notifications, feeds, progress bars, and LLM streaming.
  2. Does a closed app need to be woken? If yes, you are not choosing a transport at all: you are using the platform push service (APNs/FCM), and the OS holds the persistent connection. No amount of SSE will wake a terminated iOS app.
  3. Does an open app need sub-second one-way updates? If yes, SSE. If a 30–60 second delay is acceptable, polling with cache validators is cheaper and requires no stateful infrastructure at all.
  4. Only if all of the above fail do you reach WebSockets — and then you have signed up for the stateful gateway problem described above.

The order matters because it is a cost order, not a preference order: each step down the list adds operational surface. Reaching for WebSockets first is the single most common realtime mistake in interviews.

What "just use push notifications" actually requires

Saying "we use APNs" is a summary; the implementation is a subsystem, and interviewers who have built it will probe exactly here.

  • Device token registration and lifecycle. A device token is issued by Apple per app install per device, and it is not stable: it can change on reinstall, restore, or OS update. So registration is not a one-time event — the client re-registers, the backend upserts by token, and the token is associated with both the user and the device. When the same user signs in on a second phone, the account now has two tokens, and "send a push to this user" becomes a fanout to a device set.
  • Invalidation is mandatory, not optional. APNs answers an unregistered token with HTTP 410 (FCM has its equivalent). Ignore it and you accumulate dead tokens forever, growing send volume with no delivered pushes. The correct handling is terminal: mark the token invalid, exclude it from future sends, never retry it — the same terminal-status discipline as the notification ledger (Day 61).
  • Auth is a JWT you mint. Token-based APNs auth means signing an ES256 provider JWT with an Apple key and reusing it for up to an hour; certificate-based auth means rotating .p12 files before they expire at the worst possible moment. Token-based is the correct default, and the HTTP/2 connection to Apple should be pooled per environment, since sandbox and production are different hosts with different tokens.
  • The payload is small and the display is Apple's. Payloads are capped (~4 KB), so a push is a hint, never data: it carries an identifier and the client fetches the real thing. Anything else breaks the first time you need to send a list.
  • Collapse and priority are the two knobs that matter. apns-collapse-id makes a newer notification replace an older one for the same logical thread — the OS-level counterpart to your row-level coalescing (Day 64). Priority 5 (deferrable, may be batched or dropped) versus 10 (send immediately) is a real product decision: a streak warning at priority 10 wakes a phone; "your friend solved a problem" at priority 5 does not.
  • Delivery is not guaranteed, and you cannot know. APNs accepts the message; it does not promise the device received it. There is no delivery receipt. This is the architectural reason the in-app inbox must be the source of truth and push must be best-effort — and it is why Traverse's design is polling-first. If you had assumed push delivers, a single user with notifications disabled would see a permanently broken product.
  • Quiet hours are your problem, not Apple's. Deferring to the user's local morning needs timezone-aware, DST-safe scheduling — and the elegant implementation is to push the delivery row's next_attempt_at forward so the existing lease mechanism performs the deferral. No separate scheduler, no second cron. Reusing a primitive instead of adding one is the kind of decision that reads as experience.

Where the honest line is

Traverse has no sockets and no presence, and that is a deliberate scope decision rather than an oversight. The completeness check to run out loud: every surface has a stated freshness contract (the table in Day 74), the urgent ones get push, the stale-tolerant ones get TTL caches, and the zero-tolerance ones get direct reads. Nothing in the product is waiting on a socket that does not exist. That is what "we chose polling" should mean when you say it — not "we did not get to realtime."

Bulletproof your resume

Say these answers aloud before moving on

These are the follow-ups an interviewer will actually ask about your projects.

01

Why not WebSockets in traverse?

Because the value is one-way and latency-tolerant: notifications must reach a closed app (that's APNs/FCM's job — the OS maintains the socket), and in-app freshness needs minutes, not milliseconds, which polling with 120–600 s caches delivers. WebSockets would add a stateful gateway: connection registry, pub/sub backplane for cross-node delivery, reconnect storms, sticky routing. The honest cost of polling is a handful of requests per active user per minute — negligible at my scale, and it keeps the backend stateless.

02

Design realtime collaboration notifications for 1M concurrent users.

SSE from a stateless-ish gateway fleet: each connection pinned to a node (consistent hash on user id or sticky LB), user→node registry in Redis, events published to a Redis pub/sub (or Kafka) channel per user/room, nodes deliver to their held sockets. Per-connection bounded send queues with drop-oldest under backpressure, heartbeat pings, jittered reconnect with resume tokens (Last-Event-ID for SSE) to avoid replay storms after deploys, and connection draining on deploy via a 'reconnect to another node' hint. The bottleneck is fanout amplification: 1M sockets × 10 msg/min = ~170k msg/s of delivery — that's where per-room channels and payload dedup earn their keep.

03

What's SSE's biggest practical advantage over WebSockets?

It's plain HTTP: proxies, auth middleware, tracing, and load balancers all work unchanged, and the browser reconnects automatically with Last-Event-ID resumption built in. WebSockets need an upgrade handshake (which some corporate proxies break), custom heartbeats, and custom reconnect/resume logic. For one-way streams — dashboards, notifications, LLM token output — SSE is strictly simpler and sufficient.