System Design Networking
Long Polling & SSE
Imagine you ask ChatGPT a question and the answer appears word by word, not after a 20-second freeze. Or you watch a live auction bid tick up without hammering Refresh. The server has news for the browser, and waiting for the next full page load would feel broken.
This chapter is about getting updates from server to browser while staying mostly HTTP, without assuming exist everywhere, or that you need a full duplex (two-way) connection farm for a feed that only flows one way.
Three ways to ask for news
Ask on a timer; simple to build, but you pay for many empty requests.
Hold one request open until news or a timeout, then ask again immediately.
Keep one response open and stream event lines as they happen.
Upgrade to a two-way channel only when both sides send constant frames.
Try it: long poll cycles versus one SSE stream
Publish an event and advance ticks. Count how many HTTP requests long polling spends compared with a single SSE connection.
- Simulated tick
- 0
- HTTP requests opened
- 0
- Events delivered
- 0
- Mode
- Long poll
Long polling opens a request, holds it until data or timeout, then immediately opens another. SSE keeps one streaming response open.
What actually happens
Same product need, three HTTP-shaped answers. With , the client asks on a timer and accepts delay up to the interval; every check is a fresh request, so most of them come back empty. With , the server holds the request open until there is something to say. It is still a normal request/response, just with a long wait before the answer.
With , the server never finishes the response. It keeps writing data: lines while the browser's parses them live. Commands from client to server still use ordinary POST requests: SSE only carries traffic one way, so it does not replace those.
All three stay on ordinary HTTP, which is why they tend to work through proxies and firewalls that sometimes block (a connection upgraded into a two-way pipe). Which one fits a given feature is mostly a question of traffic direction, and the next section walks through it.
How to choose
Now that you know the three shapes, three questions decide which one a feature needs.
1. Is traffic almost entirely server to client? Notifications, tickers, dashboards, CI logs (build output), AI tokens: start with . The rare client command can be an ordinary POST beside the stream.
2. Do both sides need frequent low-latency frames? Chat, games, collaborative cursors: that is what justifies , along with the stateful connection farm that comes with them (servers that must remember every open socket).
3. Must you stay on plain HTTP through awkward middleboxes? Middleboxes are the proxies, load balancers, and corporate firewalls sitting between browser and server. and SSE often survive where WebSocket upgrades get blocked. Either way, plan , buffering fixes, and replay; the next sections cover each.
Simple rule of thumb
Default to SSE when push-only beats two-way. Use long polling when you need "wait for news" with maximum HTTP compatibility. Reach for WebSockets only when both directions need constant chatter, not because duplex sounds cooler.
What actually happens with SSE replay
Read the diagram definition
sequenceDiagram
participant Client
participant Proxy
participant Stream as Connection server
participant Log as Event log
Client->>Proxy: GET /events with Last-Event-ID 42
Proxy->>Stream: Forward, buffering off
Stream->>Log: Read events after 42
Log-->>Stream: Events 43..50
Stream-->>Client: Replay 43..50, then live stream
Stream-->>Client: Heartbeat every 25sProxy settings
The server emits an id field with events; EventSource can send the last received identifier in Last-Event-ID when reconnecting. The application must still retain and replay the corresponding history. Configure proxies to flush events and set a heartbeat interval below their idle timeout. If the identifier has expired from retention, return a documented reset path instead of pretending replay succeeded.
Poll math
1M clients polling every 5s creates 200K requests/sec even when nothing changed. SSE keeps connections open and sends small heartbeats.
Replay storage
50K events/sec x 600B x 1 hour is about 108GB raw event data before indexes and replication.
How SSE works: the two things you must get right
The diagram above shows a healthy replay end to end. The protocol handles parsing and reconnecting for you; the two things below are what it leaves to you, and they are the beats interviewers listen for. Say them in product language, then deepen if asked.
1. The reconnect contract: the browser reconnects, the server replays
reconnects for you; your server must honor replay. The split works like this:
- The server tags each event with a stable
id:field, so the client can name where it stopped. - On disconnect, EventSource reconnects on its own, honoring a
retry:delay if the server sent one. - The browser sends with the last id it saw, so the server knows the gap.
- The server replays missed events from durable storage, then resumes live streaming from the current position.
2. Middlebox pitfalls: buffering, idle timeouts, compression
Replay covers the moments a stream is down; the middleboxes from How to choose decide how often that happens. Anything between client and app that buffers, compresses, or idles-out long responses can turn a "live" stream into a delayed blob or a silent death.
Reverse proxies and CDNs (the edge servers in front of your app) may hold chunks until a buffer fills. Disable response buffering for text/event-stream paths, with headers like X-Accel-Buffering: no where the proxy honors them.
Idle timeouts
L7 balancers (load balancers that understand HTTP) and corporate proxies often close silent responses after about 30–60s. Send more often than the shortest timeout on the path.
Compression / caching
Gzip at a middlebox can delay flushes; caches must not store the stream. Prefer Cache-Control: no-cache end-to-end.
Comparison of realtime techniques
With the mechanics in hand, here is how the four options line up on direction, latency feel, and how hard the infrastructure is to run.
| Technique | Direction | Typical latency | Overhead | Complexity |
|---|---|---|---|---|
| Short polling | Client → server repeat | Higher (interval-bound) | Many idle requests | Lowest |
| Long polling | Client → server (held) | Medium | Fewer requests than short poll | Low–medium |
| SSE | Server → client stream | Low | One long response | Low |
| WebSocket | Bidirectional | Lowest | Stateful connections | Highest |
When to use each
The same trade-offs, applied to concrete products: match the pattern to whether clients mostly consume, occasionally command, or need gaming-grade two-way chatter.
| Use case | Best technique | Why |
|---|---|---|
| Email inbox refresh | Short polling | Low frequency, simple |
| Chat (large social app, 2008 era) | Long polling | Server→client, works everywhere |
| Auction / live bid prices | SSE | Server→client push; WebSocket usually overkill |
| Live sports scores | SSE | Server→client push, auto-reconnect |
| Stock ticker | SSE | Continuous server updates |
| Chat (modern) | WebSocket | Bidirectional, high frequency |
| Multiplayer gaming | WebSocket | Low latency bidirectional |
| Live dashboard | SSE | Server pushes metric updates |
| Notifications | SSE or long polling | Infrequent server→client events |
| Collaborative editing | WebSocket | Bidirectional conflict resolution |
| IoT device commands | Long polling or MQTT-style | Simple clients, works on any HTTP |
| Need bidirectional? | → WebSocket | |
| Server→client only? | → SSE (first) or long polling | |
| Binary data needed? | → WebSocket (SSE is text-only) |
How to choose WebSocket, SSE, or polling
WebSocket
Use for frequent two-way messages. Budget memory per connection, heartbeats, reconnect storms, sticky routing, and backpressure.
SSE
Use for server-to-client streams over HTTP. It has automatic reconnect and simple text events, but client commands still need separate requests.
Polling
Use when freshness can be seconds and simplicity matters. Lower intervals improve freshness but multiply empty requests.
Failure handling
An event stream that must preserve every update needs a replay cursor and retained history. A polling endpoint that returns the latest complete state may simply read a fresh snapshot instead. In both cases, spread retries over time and limit work for slow clients. Recovery requirements depend on whether the product promises event history or only the current state.
Simple rule of thumb
Frame the answer around freshness, directionality, connection count, and replay. Then size fan-out and memory before promising realtime.
Chat at 1M online
If 5% send 2 messages/min and each fans out to 20 recipients: 50K senders x 2 / 60 x 20 = ~33K outbound messages/sec.
Feed updates
For one million users receiving five updates per minute, outbound rate is 1,000,000 × 5 ÷ 60, or about 83,333 events per second. At 600 bytes per event, that is 50 MB/s of payload before protocol overhead. These are delivered copies: if one source event reaches many users, count every recipient when estimating outbound bandwidth.
Connection memory
1M open sockets x 15KB/server state is ~15GB RAM, before queues, presence maps, and per-room indexes.
Long polling deep dive
A long-poll response ends after an event or timeout, and the client immediately issues another request with its last received position. The next request can reuse the existing HTTP connection, so a new request does not inherently mean a new TCP and TLS handshake. Events produced between the response and the next request must remain available for replay; keeping a request open alone does not prevent this gap.
The math above sizes the traffic; now look closely at the older of the two HTTP tricks. holds a request open until news arrives or a timer fires, approximating push with plain request/response.
How it works
Each cycle is a normal HTTP request that stays open on the server side until there is something to say, or until the wait budget expires.
Server accepts the connection and holds it: no bytes yet, until data is ready or an internal timer fires.
As soon as the client gets 200, it opens another GET /poll so the server can hold again: the loop that approximates a push channel.
No payload arrives within the agreed window (about 30s in the sketch), so the server closes the wait with an empty success.
Client treats that as "no news yet" and starts the next GET /poll.
Implementation pattern
The three steps on the left collapse into a few lines: the server awaits a queue with a timeout, and the client re-polls the moment a response lands.
# Server (async handler)
async def poll(request):
user_id = request.user.id
timeout = 30 # seconds
# Wait for new data or timeout
data = await message_queue.get(
user_id, timeout=timeout
)
if data:
return JsonResponse(data)
return HttpResponse(status=204)
# Client (JavaScript)
async function longPoll() {
const res = await fetch('/poll');
if (res.status === 200) {
handleMessage(await res.json());
}
longPoll(); // immediately re-poll
}Long polling at scale (2008–2014). This simple loop carried real load before WebSockets were ubiquitous. Large social apps served hundreds of millions of concurrent chat users with long polling. Each user held one open HTTP connection, async channel servers managed millions of held connections per machine, and messages landed within 100–300ms. They eventually migrated to mobile messaging protocols and WebSockets for web, but long polling proved that simple techniques can scale far.
Server-Sent Events deep dive
Long polling fakes push with repeated requests; gets there with a single response, as you saw in What actually happens. This section shows exactly what that looks like on the wire, then in code, then in products.
SSE is just HTTP, with one twist
SSE uses an HTTP response with Content-Type: text/event-stream and keeps that response open while events are available. It does not need a WebSocket upgrade, but proxies still require suitable buffering and timeout settings. Both SSE and polling can reuse an established HTTP connection; SSE saves repeated request overhead by carrying several events in one response. The server can finish the stream, after which the client follows its reconnection policy.
GET /events HTTP/1.1
Host: api.example.com
Accept: text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream ← this is the magic header
Cache-Control: no-cache ← don't buffer events
Connection: keep-alive ← keep the pipe open
event: price_update
id: 1001
retry: 3000
data: {"symbol": "AAPL", "price": 189.42}
event: price_update ← server keeps writing...
id: 1002
data: {"symbol": "GOOG", "price": 141.80}
← connection stays open foreverSSE event format reference
An event is a sequence of UTF-8 text lines terminated by a blank line. Multiple data lines form one payload; an event field names the event type, and an id field records a resumable position. A comment line can act as a heartbeat without triggering a data event. Flush complete events through the application and proxy path, because an open connection with buffered output can look healthy while the user receives nothing.
The exchange above used four field names. Each event is a few text lines ending in a blank line, and the fields below are the whole vocabulary.
HTTP Response Headers:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Event Stream Format:
event: message ← named event type (optional)
id: 12345 ← event ID for reconnection tracking
retry: 3000 ← reconnect delay in ms
data: {"user": "alice"} ← payload (can be multi-line)
event: notification
id: 12346
data: {"type": "like", "count": 42}
← double newline = event boundarySSE key features
- Auto-reconnect: the browser reopens the stream on its own after any disconnect.
- : sent on reconnect so the server can replay (see the reconnect contract above).
- Retry field: the server controls the reconnect delay, which defaults to 3 seconds.
- Named events: the
event:line lets the client subscribe to one type at a time. - Text-only: UTF-8 text with no binary frames, so binary payloads need WebSocket instead.
Worked example: a stock ticker over SSE
The headers and fields above are all a real ticker needs. The JavaScript server below writes a price event every second; the browser subscribes with and gets reconnection for free.
Server
app.get('/stocks', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Send price every second
const interval = setInterval(() => {
const data = {
symbol: 'AAPL',
price: (180 + Math.random() * 10).toFixed(2),
time: new Date().toISOString()
};
res.write(`event: price\n`);
res.write(`id: ${Date.now()}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
req.on('close', () => clearInterval(interval));
});Client (browser)
const source = new EventSource('/stocks');
source.addEventListener('price', (e) => {
const { symbol, price, time } = JSON.parse(e.data);
document.getElementById('ticker').textContent =
`${symbol}: $${price}`;
});
// Auto-reconnect is FREE
source.onerror = () => {
console.log('Connection lost, reconnecting...');
// Browser auto-reconnects in 3s (or retry: value)
// Sends Last-Event-ID header automatically
};
// Clean up
window.addEventListener('beforeunload', () => {
source.close();
});The same shape with replay and named events
Replay and live subscription need a shared ordering boundary. If the server reads old events and only afterward starts listening, an event produced between those actions can be missed. Use a retained log cursor or a subscribe-and-catch-up procedure that bridges the two phases. The client should tolerate a duplicate at the boundary. If its cursor has expired from retention, provide a fresh snapshot and a new cursor explicitly.
The ticker above would lose events across a reconnect. Once the server reads on the way in and subscribes from that point, the stream becomes replayable; the Python server below does exactly that, and the client listens for two named event types.
Server implementation
# Python (async web framework)
async def event_stream(user_id):
last_id = request.headers.get(
'Last-Event-ID', 0)
async for event in subscribe(
user_id, after=last_id):
yield (
f"event: {event.type}\n"
f"id: {event.id}\n"
f"data: {event.json()}\n\n"
)
return StreamingResponse(
event_stream(user_id),
media_type="text/event-stream"
)Client (EventSource API)
// Browser JavaScript
const source = new EventSource(
'/events?userId=123'
);
source.addEventListener('message',
(e) => {
const data = JSON.parse(e.data);
displayMessage(data);
});
source.addEventListener('notification',
(e) => {
showNotification(JSON.parse(e.data));
});
source.onerror = () => {
// Auto-reconnects with Last-Event-ID
console.log('Reconnecting...');
};Where SSE shows up in products
The ticker is one instance of a pattern you meet everywhere the server has news and the client mostly watches.
Financial data is the classic case: stock tickers, crypto prices, and forex dashboards take a server push every second. Notifications are the quiet case: developer APIs and social feeds push alerts without polling. CI/CD pipelines (the systems that build and deploy code) are the satisfying case: build logs stream line by line, and each line is an event users see live.
SSE for AI: token streaming
Streaming improves time to first useful output, but it does not make model computation instantaneous or guarantee a fixed first-token latency. A token-generation request may use fetch with a streamed response, especially when a POST body or authorization header is needed; the native EventSource interface initiates GET requests and has a narrower API. Reconnection behavior therefore depends on the client implementation, not just an event-stream content type.
is the protocol powering many AI chat interfaces. LLM (large language model) UIs stream tokens, the word fragments a model produces, as they are generated. That is what makes the word-by-word answer from the opening story possible.
Why SSE fits AI streaming
- LLMs emit tokens one at a time (50–100 per second), so a stream matches the source.
- Flow is strictly server to client: the prompt goes up via POST, then the client listens.
- Nothing travels back mid-answer, so a two-way adds state without adding value.
- It works through proxies, CDNs, and load balancers with no WebSocket state to manage.
How a token streaming API does it
POST /v1/chat/completions
{"model":"gpt-4","stream":true,...}
Response (SSE stream):
data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" world"}}]}
data: {"choices":[{"delta":{"content":"!"}}]}
data: [DONE]Without SSE: the user waits 10–30 seconds for the full response. With SSE: the first token appears in <500ms. Perceived latency drops from "is it frozen?" to "it's typing."
What breaks in production
Each stream needs a bounded outgoing buffer. If a client reads slower than the event producer, backpressure eventually reaches the server or its buffer grows without limit. For durable logs, disconnect the client and let it resume later; for a dashboard, consider replacing older queued snapshots with the newest state. Heartbeats should occur before intermediary idle timeouts, while reconnects should use randomized backoff to spread recovery load.
Everything so far assumed a healthy connection. In production, long-lived HTTP responses get severed, held connections eat memory, and reconnects arrive in waves. This card collects those failures and the questions that surface them early.
Connections get severed, so replay is not optional
connections are long-lived HTTP responses, and many things in the network path will kill them:
What kills SSE connections
- Proxy timeouts: reverse proxies and L7 balancers often idle-kill silent streams around 60 seconds.
- CDN buffering: the edge may hold the stream in a buffer, adding delay before delivery.
- Mobile networks: carriers drop idle TCP connections after roughly 30–60 seconds of silence.
- NAT tables expire: home routers forget idle connections (NAT maps private addresses to public ones).
- Server deploys: a rolling restart drops every open stream on each node it cycles.
What happens to missed events?
Recovery is the reconnect contract from How SSE works above: the browser reconnects with the last id it saw (say : 1002), the server sends everything after it, then goes live. The code below is the server half of that contract, and the trap is unchanged: your server must store events durably, because the protocol gives the mechanism, not the storage.
// Server: handling reconnection with Last-Event-ID
app.get('/events', async (req, res) => {
const lastId = req.headers['last-event-id'] || '0';
// 1. Replay missed events from storage
const missed = await db.query(
'SELECT * FROM events WHERE id > ? ORDER BY id', [lastId]
);
for (const event of missed) {
res.write(`id: ${event.id}\ndata: ${event.data}\n\n`);
}
// 2. Then stream live events
const subscriber = pubsub.subscribe('events', (event) => {
res.write(`id: ${event.id}\ndata: ${event.data}\n\n`);
});
req.on('close', () => pubsub.unsubscribe(subscriber));
});Scalability challenges
Once replay is solved, the next problem is volume: many hanging connections consume sockets and memory, so plan timeouts, proxies, and horizontal fan-out.
challenges
Long polling creates a new HTTP request after each response, but HTTP connection reuse can avoid a new TCP and TLS handshake. Timeouts can still synchronize many new requests, so add jitter and bounded retry backoff. Held requests consume server and intermediary state; measure memory, file descriptors, and oldest waiting-request age before selecting a connection count per instance.
SSE challenges
Browser limit: about 6 SSE connections per domain on HTTP/1.1; HTTP/2 lifts this to 100+ streams sharing one connection. Load balancing: long-lived SSE sockets behave like for fairness, so prefer least-connections (route new streams to the node with the fewest open ones) so one node does not soak up all open responses. Memory per connection: at 1M streams, use connection servers that from pub/sub (a publish/subscribe message bus), so app logic and open sockets scale separately.
Questions to ask before you pick
Each failure above maps to a question you can settle in the first minutes of a design discussion.
- Is the stream server-to-client only, or does the client need frequent low-latency messages?
- How fresh must updates be, milliseconds, seconds, or eventually? That bounds the polling interval.
- Do clients run in browsers, mobile apps, IoT devices, or corporate networks with strict proxies?
- Can missed events be replayed using IDs, or are they disposable and safe to drop?
- How many concurrent open connections must one region hold, and across how many servers?
Wrap-up
What to remember
You have seen the three shapes, the reconnect contract, and what breaks at scale. At large scale, use a : app servers publish to pub/sub, and dedicated connection servers push via or long polling. That decouples business logic from open sockets, and each connection server can hold 100K–500K concurrent connections with async I/O. For most server-to-client push, SSE is the best default: simpler than , auto-reconnects, works through HTTP/2 multiplexing (many streams sharing one connection), and handles many edge cases for you.
Primary sources
- HTML Living Standard: Server-Sent Events: the SSE event stream model and the EventSource reconnect behaviour.
- RFC 6202: Known Issues with HTTP Streaming: long polling and streaming pitfalls, including the middlebox problems above.
- RFC 9110: HTTP Semantics: request/response constraints for long-held GETs.
Practice and further reading
Use these exercises to connect the lesson to a design decision. State your assumptions, trace what happens to one request, and explain the rule that must remain true even during a failure. That rule is the design's correctness invariant.
Focus for this lesson: Implement HTTP-friendly server updates with reconnect, replay, proxy buffering controls, auth renewal, and honest connection limits.
Explain the decision
How does Last-Event-ID make SSE recoverable?
Read the reasoning
The browser reconnects with the last delivered event ID; the server resumes from retained history or declares that a full resync is required.
Apply it to a production scenario
SSE events arrive in one-minute batches in production but instantly locally. What do you check?
Read one approach
Disable proxy compression/buffering for the stream, flush event frames, send heartbeats, and inspect idle timeouts at every hop.
Work through the numbers
200,000 SSE clients send a 40-byte heartbeat every 20 seconds. Estimate raw heartbeat payload rate.
Read the worked calculation
400,000 bytes/s, about 3.2 Mb/s, before HTTP chunking, TCP/IP, and TLS overhead.
Reason through a failure
What must token refresh avoid for a long-lived stream?
Read the recovery reasoning
The client must renew or reconnect before expiry without losing its replay position or creating parallel duplicate subscriptions.
Sources and further study
Continue with Scalability Principles.