The Poll-or-Subscribe Decision
The Poll-or-Subscribe Decision
Both approaches move updates to the user; they differ in who pays and when. Polling charges the client request budget and adds latency equal to roughly half the interval. A subscription charges server memory and connection state and delivers in milliseconds. Neither is free, so the choice follows data shape, not fashion.
| Feature | Updates per viewer | Freshness the user accepts | Fit |
|---|---|---|---|
| Chat room | 1-30/s during conversation | under 500 ms | subscription |
| Live auction bidding | 2-10/s | under 200 ms | subscription |
| Ops dashboard, 40 widgets | bursty, clustered | 2-5 s | throttled subscription |
| Notification badge | 0-5/day | 30 s | polling |
| Product price on a detail page | rare | 5 minutes | polling behind a cache |
| Admin audit log | rare, burst during an incident | 10 s | polling or hybrid |
Three questions settle most design reviews:
- How often does data change per viewer? Below one change per 30 seconds, a 15-second poll costs the user almost nothing and the server nothing persistent.
- How fresh must it be? If a 3-second-old number is acceptable, poll. If a missed 200 ms bid loses an auction, push.
- What does holding a connection cost? Each open stream pins memory, a timer or cursor, and a slot in your connection table. Ten thousand simultaneous dashboards is a capacity plan, not a detail.
The arithmetic is worth saying out loud. One client polling every 5 seconds issues 17,280 requests a day; the equivalent subscription issues one request and then holds memory for all 24 hours. Rare, bursty data favors polling; dense, continuous data favors a stream.
Polling also wins on operations: no reconnect logic, no broker, no half-open sockets, and horizontal scaling is just more stateless instances. "Ship it with polling and add subscriptions when the numbers justify it" is a defensible plan for a weekly digest and a bad one for a chat product.
Features That Earn a Stream
Features That Earn a Stream
Four patterns account for most production subscriptions, and each carries its own scoping rule.
1. Chat and threaded messaging. messageAdded(roomId:) is the canonical case: high frequency, tight freshness, and a natural key the client already holds. The resolver shape lives in subscription lifecycle; the design question here is what happens to messages sent while a user was away — a stream is not history, so the room still needs a paginated query on rejoin.
2. Live dashboards. Widgets do not need raw event rates; they need a tick:
subscription Dashboard($fleet: ID!) {
dashboardTick(fleetId: $fleet) {
at
metrics { cpuP95 queueDepth errorRate }
}
}
A server-side aggregator batches upstream churn into one tick per second, so subscriber count stops correlating with event rate. Batching on the client is the trap: multiplying every raw event by ten thousand subscribers is exactly the fan-out you were trying to avoid.
3. Notifications and inbox badges. Low frequency, but users notice a stale badge immediately — the badge often is the product. Polling a badge every 10 seconds per user is the classic waste case: thousands of requests an hour to deliver a handful of updates.
4. Presence and collaboration. Cursors, typing indicators, who is viewing a document. This state exists only while connections do; there is no row to poll, so a stream is the only faithful transport. It also means presence must decay on disconnect — a client that vanishes without unsubscribing would otherwise appear online forever.
What they share
Every one scopes by a key the client already knows — room, fleet, user, document — and refuses anything unscoped. An open messageAdded that streams every message in the system is simultaneously a performance problem and a data leak, which is the shape interviewers like to ask you to defend against.
Fan-out, Connect-Time Auth, and Cost
Fan-out, Connect-Time Auth, and Cost
A single instance fans out to its own subscribers with an in-process map. Two instances cannot share that map, so a publish arriving on one node must reach subscribers sitting on another:
publisher --> [ broker: Redis / Kafka / NATS ]
| |
instance A instance B
local subs local subs
Each instance subscribes to a topic once and delivers only to connections it owns. Never route publishes through the load balancer and never rely on sticky sessions to keep subscribers together — a redeploy moves them anyway, which is precisely when delivery matters most.
Authorization belongs at the handshake
// graphql-ws connection_init payload, or the SSE Authorization header
const user = await verifyToken(connectionParams.authorization);
if (!user) throw new Error('UNAUTHORIZED'); // reject before any subscribe is accepted
Checking authorization only when a subscribe message arrives leaks in two ways. A revoked token keeps delivering on a stream opened before revocation, because no new message ever triggers the check. And a client that passes once can hold the stream open while its permissions change underneath. Establish identity at connect time, re-check sensitive topics per event as defense in depth, and close streams when access is revoked — the revoke path is what teams forget to write.
Putting a number on it
A WebSocket costs roughly 2-10 KB of server memory plus a file descriptor, held for minutes; an HTTP request costs nothing once it returns. That asymmetry is why connection counts belong on the capacity dashboard next to CPU, and why per-client stream caps are a scaling feature rather than a courtesy. A deploy that doubles connections while halving traffic per connection still fails if the connection table is the constraint.
Interview framing: name the three axes that decide poll versus push (change rate, freshness tolerance, connection cost), then describe broker fan-out and the connect-time auth check as the two things that make a subscription design production-ready.
Practice Problems
A product team proposes six realtime features. For each, pick polling, a throttled subscription, or a raw subscription using update rate per viewer and acceptable staleness, then name the scoping key and the connect-time check for anything you route to a subscription.
Example:
Input: Chat: 3 messages/second at peak, users notice anything above 500 ms
Output: subscription messageAdded(roomId:), authenticated at connection_init, scoped by roomId
High change rate plus tight staleness justifies holding a connection; the room id already known to the client narrows the stream before delivery.
Optimal Solution — O(1) per feature time, O(1) space
Apply three gates in order: does state exist only while connected, how many changes per minute does one viewer see, and what staleness does the user tolerate. Subscription only wins when change rate is high or state is connection-bound; otherwise prefer the stateless option.
function chooseTransport(f) {
if (f.stateExistsOnlyWhileConnected) return 'subscription';
const perMinute = f.updatesPerViewerPerMinute;
const staleMs = f.maxAcceptableStalenessMs;
if (perMinute >= 2 && staleMs <= 5000) return 'subscription';
if (perMinute >= 12 && staleMs <= 10000) return 'throttled subscription';
if (staleMs <= 60000) return 'polling';
return 'polling with a cached edge response';
}
// Chat -> subscription (dense + strict staleness)
// Auction -> subscription (dense + strict staleness)
// Dashboard -> throttled sub (batch to 1 tick/s on the server)
// Badge -> polling (rare updates)
// Price -> polling + cache (rare, tolerates minutes)
// Audit log -> polling (rare, bursts covered by refresh) Edge Cases:
- A feature that is rare but connection-bound, such as presence
- A dashboard whose upstream rate exceeds what one tick can carry
- A feature that needs both directions, such as live editing
Quiz
1. A status page updates twice a day and users accept a 60-second delay. Which transport fits best?
2. Why does authorization need to run at connection setup rather than only when each subscribe message arrives?
3. Two API instances each hold subscribers for the same topic. What must publish do for every subscriber to receive the event?
Flashcards
Question
When polling beats subscriptions
Click to reveal answer
Answer
Updates are rare and a few seconds of latency is acceptable — polling keeps the server stateless and skips reconnect logic, broker plumbing, and idle-socket handling.
Question
Fan-out topology across instances
Click to reveal answer
Answer
Publish to a shared broker (Redis/Kafka/NATS); each instance consumes once and delivers to its own local subscriber table. Never fan out through the load balancer.
Question
Where subscription auth must run
Click to reveal answer
Answer
At the connection handshake (connection_init payload or SSE header), plus per-event re-checks on sensitive topics; streams must close when access is revoked.
Question
Batching rule for dashboards
Click to reveal answer
Answer
Aggregate upstream churn into a server-side tick so delivery cost scales with ticks, not with raw events multiplied by subscriber count.
Question
What presence state requires besides a stream
Click to reveal answer
Answer
Decay on disconnect — without unsubscribe-driven cleanup and heartbeat expiry, vanished clients still appear online.
Revision Notes
Key Takeaways
- 1. Decide by change rate, freshness tolerance, and connection cost — not by which transport is newer
- 2. Scope every subscription by a key the client already knows; unscoped streams are slow and leaky at once
- 3. A shared broker plus a local subscriber table is the fan-out topology that survives deploys
- 4. Connect-time auth and stream caps turn a demo socket into something that can be operated
Interview Tips
- • Quantify the choice: requests per day for polling versus held memory for a stream, then state which side you take and why
- • Answer the two follow-ups that separate designs from demos: what happens on reconnect, and what happens when a token is revoked
- • Name the broker and the local delivery table when asked how subscriptions scale past one process
Cheat Sheet
Realtime Feature Patterns
Decision inputs
| Signal | Favors |
|---|---|
| More than 1 change/sec per viewer | subscription |
| Seconds of latency fine, changes rare | polling |
| Frequent client-to-server messages as well | WebSocket |
| Strict proxies, one-way data | SSE transport |
Rules of thumb
- Poll every 60 s = 1,440 requests/day per viewer; a stream = 1 request plus held memory.
- Aggregate on the server: one tick beats N raw events times M subscribers.
- Scope by key (room, fleet, document); refuse unscoped subscribe.
- Auth at handshake, close streams on revoke, cap streams per user.
- Publish to a shared broker; deliver locally per instance.