Worked system design solution
Design: Polling vs Long-Polling vs SSE vs WebSocket
A comparative design exercise — when do you choose short polling, long polling, Server-Sent Events, or WebSockets for client-server real-time communication? Each has a place; the wrong choice cripples scalability or wastes bandwidth.
Requirements
When clients need server-pushed updates
The problem
A client (browser, mobile, IoT) needs to receive updates from the server. The server, not the client, knows when there's new data. How does the client find out?
Concrete scenarios:
- Chat: new messages arrive any time.
- Stock prices: tick updates many per second.
- Notifications: "your order shipped" arrives at any unpredictable time.
- Sports score: occasional events, varying cadence.
- Collaborative editor: every keystroke from other users.
Requirements
For each scenario:
- Latency target: how fast must the update reach the client?
- Update frequency: how often does the server have new data?
- Connection count: how many concurrent clients?
- Bandwidth budget: mobile data caps matter.
- Server cost: persistent connections vs request/response.
The four mechanisms we compare
- Short polling — client asks "anything new?" every N seconds. Server responds immediately.
- Long polling — client asks "anything new?"; server holds the request open until new data arrives.
- Server-Sent Events (SSE) — server keeps a one-way HTTP stream open and pushes events.
- WebSocket — full-duplex persistent connection over TCP.
Interview: The interviewer often asks "design a chat app" or "design a stock ticker." The first decision is which of these mechanisms you pick — and why. The four trade off latency vs server-cost vs implementation complexity.
Mechanism Comparison
Latency, cost, complexity, browser support
The matrix
| Mechanism | Latency | Server cost | Bandwidth | Complexity | Direction |
|---|---|---|---|---|---|
| Short polling | up to N sec | High (request/sec) | High | Low | Both (each poll) |
| Long polling | < 1 sec | Medium (holds conn) | Medium | Medium | Both (each response) |
| SSE | < 100 ms | Low (1 conn / client) | Low | Low | Server → client only |
| WebSocket | < 50 ms | Low (1 conn / client) | Lowest | High | Full-duplex |
When to pick which
| Scenario | Best mechanism | Reasoning |
|---|---|---|
| Chat / messaging | WebSocket | Bidirectional + low latency for messages both ways |
| Stock ticker (read-only) | SSE | One-way push, simple HTTP, auto-reconnect |
| Notifications (rare events) | SSE or long-poll | Low frequency; persistent connection is overkill |
| Collaborative editor | WebSocket | Full-duplex, many small ops per second |
| Sports score updates | SSE | One-way, low frequency, mobile-friendly |
| Background sync (every 5 min) | Short polling | Cheap and simple; latency tolerance is high |
| Multiplayer game | WebSocket | Server pushes state + client sends input |
Underlying transport
| Mechanism | Protocol | Reconnect strategy |
|---|---|---|
| Short poll | Plain HTTP | None — just poll again |
| Long poll | Plain HTTP (hold) | Issue another GET on close |
| SSE | HTTP/1.1 chunked | Native auto-reconnect with Last-Event-Id |
| WebSocket | TCP upgrade from HTTP | Client-side manual reconnect logic |
Architecture: Short Polling
Diagram + when it actually wins
How it works
sequenceDiagram
autonumber
participant C as Client
participant S as Server
loop every N seconds
C->>S: GET /updates?since=T
S-->>C: 200 [list of events]
end
Pros
- Simple: just HTTP. Works through every firewall, every proxy, every CDN.
- Stateless: server holds no connection state per client.
- Cacheable: responses can be cached at the CDN if updates are infrequent.
- Mobile-friendly: phone radio sleeps between polls (battery wins).
Cons
- Latency = average half the poll interval. 30s polling → ~15s average latency.
- Wasted requests: 95% of polls return "nothing new" — pure overhead.
- Server cost: 1M clients polling every 5s = 200 K req/s.
When it actually wins
- Long-tail updates: status pages, "is build done?", low-priority notifications.
- Mobile battery > latency: occasional poll on app-open is better than holding a connection.
- Server-side simplicity matters: no connection-tracking machinery needed.
Example flow
GET /v1/notifications?since=2026-05-24T11:59:00Z
{
"events": [
{ "id": "ev_a", "type": "comment", "createdAt": "..." }
],
"watermark": "2026-05-24T12:00:00Z"
}
Client uses watermark as the next since value to avoid re-fetching seen events.
Architecture: Long Polling
The intermediate sweet spot
How it works
sequenceDiagram
autonumber
participant C as Client
participant S as Server
C->>S: GET /updates?since=T
Note over S: hold connection open<br/>up to 30s
alt new data arrives
S-->>C: 200 [event]
else timeout
S-->>C: 200 [] (empty)
end
C->>S: GET /updates?since=T2 (reconnect)
Pros
- Near-instant latency (~50-200 ms) when events are infrequent.
- Still plain HTTP — works through proxies and firewalls.
- No new protocol — backend engineers know HTTP intimately.
- Backpressure built-in: server controls when to respond.
Cons
- Server holds connections: at scale you need event-driven (Node, Go, Tornado) — thread-per-request servers fall over.
- Reconnection overhead: every event closes the connection; client opens a new one. TCP handshake + TLS each time.
- Proxy timeouts: some corporate proxies kill idle HTTP connections after 30s. Must respond by then.
When it wins
- Notifications-style: rare events, want immediate delivery.
- Pre-WebSocket browsers (legacy compat).
- Server already does request/response well — long-poll is a small step up.
Server-side requirement
The server needs to:
- Accept the GET and not respond immediately.
- Subscribe to an event source (Redis pub-sub, Kafka, etc.) keyed by
sinceor user-id. - Respond when an event arrives (or 30s timeout, whichever first).
app.get('/updates', async (req, res) => {
const subscriber = redis.duplicate();
const timer = setTimeout(() => { subscriber.disconnect(); res.json({ events: [] }); }, 30_000);
subscriber.subscribe(`events:${req.user.id}`, () => {});
subscriber.on('message', (channel, msg) => {
clearTimeout(timer);
subscriber.disconnect();
res.json({ events: [JSON.parse(msg)] });
});
req.on('close', () => { clearTimeout(timer); subscriber.disconnect(); });
});
Architecture: Server-Sent Events
One-way streaming — the underrated workhorse
How it works
sequenceDiagram
participant C as Client
participant S as Server
C->>S: GET /events Accept: text/event-stream
S-->>C: 200 + persistent connection
loop server pushes
S-->>C: data: event 1
S-->>C: data: event 2
S-->>C: : keep-alive
S-->>C: data: event 3
end
Note over C,S: client disconnect/reconnect handled by browser
Wire format
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: { "type": "comment", "id": "..." }
id: 42
event: chat
data: { "from": "alice", "text": "hello" }
: this is a heartbeat / comment
Each event is separated by a blank line. Lines starting with : are comments (keep-alives).
Pros
- Native browser support —
new EventSource('/events')and the browser handles reconnection, includingLast-Event-Idon the resume request. - Plain HTTP: works through CDNs that support streaming (Cloudflare does; some don't).
- One connection per client — server holds N persistent connections, not N×polls/sec.
- Auto-reconnect with replay: server can keep an event log and replay missed events when client reconnects with
Last-Event-Id.
Cons
- One-way (server → client only). Client uses normal HTTP for sends.
- No binary — events are UTF-8 text. Binary needs base64 (15% overhead).
- HTTP/1.1 6-connection limit per origin — multiple SSE streams from the same browser to the same domain compete. Use HTTP/2 (no limit) or different subdomains.
When SSE wins
- News feeds, score updates, notifications, log streaming, GPT-style token streaming.
- The frontend is a browser (server-side SSE clients are less standardized).
- Updates are one-way from server.
Client code
const es = new EventSource('/v1/events?token=...');
es.addEventListener('chat', (e) => {
const data = JSON.parse(e.data);
console.log('New message:', data);
});
es.onerror = () => console.log('Reconnecting...'); // browser auto-reconnects
Architecture: WebSocket
Full-duplex — the most powerful, most complex
How it works
sequenceDiagram
participant C as Client
participant S as Server
C->>S: GET /ws Upgrade: websocket
S-->>C: 101 Switching Protocols
Note over C,S: TCP connection now WebSocket
par
C->>S: msg "hello"
S->>C: msg "welcome"
C->>S: msg "..."
S->>C: msg "..."
end
Note over C,S: ping/pong frames every 30s
C->>S: close
S->>C: close ack
Pros
- Full-duplex: both sides can send messages at any time without request/response overhead.
- Lowest latency of any browser-based option.
- Binary support native (no encoding overhead).
- Lowest bandwidth for high-frequency back-and-forth.
Cons
- Stateful: server holds open TCP socket per client. 1M clients = 1M sockets.
- No built-in HTTP semantics: caching, redirects, auth headers — all manual.
- Proxy support varies: some corporate proxies block or break the upgrade.
- Reconnection logic: client-side. No built-in retry/backoff.
- Subprotocol design: you invent the message schema (JSON-over-WS is common; some use protobuf).
When WebSocket wins
- Chat / messaging — full duplex matters.
- Multiplayer games — bidirectional state + input streams.
- Collaborative editing — many small ops per second in both directions.
- Trading dashboards — many subscriptions multiplexed over one socket.
Server-side architecture
flowchart TB
LB["L4 LB<br/>sticky on connection-id"]
C1[Client 1] --> LB
C2[Client 2] --> LB
LB --> WS1["WS server 1<br/>holds 10K conns"]
LB --> WS2["WS server 2<br/>holds 10K conns"]
WS1 & WS2 <--> Pubsub["(Redis Pub/Sub<br/>fan-out across instances)"]
- LB must be L4 sticky (or use connection-id consistent hashing) so reconnects land back where the session lives — unless you use Pub/Sub fan-out which makes this not matter.
- Each WS server holds N connections. Modern Go/Node: 10K-50K per box.
- Redis Pub/Sub fans messages out across all WS servers so any client connected to any server gets messages addressed to them.
Heartbeats
WebSockets don't tell you the connection died until you try to send. Solution: send a ping every 30s; if no pong in 30s, close the socket.
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.ping();
}, 30_000);
Scaling Real-Time at Fleet Scale
Pub/Sub fan-out, connection sharding, partition strategies
The fan-out problem
A message addressed to user U must reach U's connection — but U might be connected to any of N WebSocket servers.
Solution: Pub/Sub layer
flowchart LR
Source["Event source<br/>backend API"] --> Pub["Publish on Redis<br/>channel = user:U"]
Pub --> WS1["WS-server-1<br/>SUBSCRIBE user:U"]
Pub --> WS2["WS-server-2<br/>SUBSCRIBE user:U"]
WS1 -->|"if U connected here"| U1[User U's socket]
WS2 -.->|"U not connected here"| Skip[ignore]
Each WS server subscribes to channels for the users it's hosting (efficient — wildcard subscriptions don't scale). When a new connection arrives, the server SUBSCRIBE-s to that user's channel.
Connection sharding
For very large fleets:
- Hash
userId→ assign to a specific WS-server-shard. - LB does consistent hashing on userId.
- Eliminates the cross-server fan-out for direct messages (server already has the connection locally).
Backpressure
Slow clients accumulate undelivered messages in the server's send buffer.
| Strategy | Behavior |
|---|---|
| Drop oldest | Discard old messages when buffer is full |
| Drop newest | Reject new sends when buffer is full |
| Disconnect | Close the slow connection (force reconnect) |
For chat: drop oldest is reasonable. For trades: disconnect (the client must catch up explicitly).
At scale numbers
| Fleet size | Recommendation |
|---|---|
| 1K clients | Single Node/Go process |
| 100K clients | 10-20 WS servers + Redis Pub/Sub |
| 1M clients | Sharded WS servers + Kafka for durable fan-out + sticky LB |
| 10M+ clients | Multi-region; per-region fan-out; geo-routing |
Decision Framework
How to pick — and when to combine
The decision tree
flowchart TD
Q1{Need bidirectional<br/>real-time?}
Q1 -->|Yes| WS[WebSocket]
Q1 -->|No| Q2{Need < 1s latency?}
Q2 -->|No| Poll[Short polling]
Q2 -->|Yes| Q3{Many clients,<br/>frequent updates?}
Q3 -->|Yes| SSE[Server-Sent Events]
Q3 -->|No| LP[Long polling]
Hybrid patterns
In production, you often combine:
Pattern 1: SSE + plain HTTP POST
- SSE for server→client events.
- Plain POST for client→server actions.
- Simpler than WS, almost as capable, for chat-like apps where send rate is modest.
Pattern 2: WS for the active tab + polling when backgrounded
- Browser tabs in background pause WebSocket activity (some browsers).
- Switch to short polling every minute when backgrounded; resume WS on focus.
Pattern 3: WS with HTTP fallback
- Library (Socket.IO, Phoenix Channels) automatically falls back from WS to long-poll to short-poll based on environment capability.
The trade-off summary
Interview: If you can only remember one rule: pick the simplest mechanism that meets your latency requirement. Don't reach for WebSocket because it's powerful — reach for it because the application genuinely needs full-duplex. Many "real-time" features ship perfectly well on SSE or even long-polling, with less operational complexity.
Real-world examples
| Product | Mechanism | Why |
|---|---|---|
| Slack messages | WebSocket | Chat, full-duplex, low latency |
| Twitter notifications | SSE | Server-push, no client-send needed |
| GitHub Actions logs | SSE | Log streaming, one-way |
| Trello board updates | WebSocket | Multi-user collaboration |
| Build status badge | Short poll | Status check; latency tolerance high |
| Stripe webhooks (server→server) | HTTP POST | Different problem entirely (push from S1 to S2) |