SwadhyAI Notes

All System Design notes

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

1

Ask on a timer; simple to build, but you pay for many empty requests.

2

Hold one request open until news or a timeout, then ask again immediately.

3

Keep one response open and stream event lines as they happen.

4

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

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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 25s

Proxy 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:

  1. The server tags each event with a stable id: field, so the client can name where it stopped.
  2. On disconnect, EventSource reconnects on its own, honoring a retry: delay if the server sent one.
  3. The browser sends with the last id it saw, so the server knows the gap.
  4. The server replays missed events from durable storage, then resumes live streaming from the current position.
Trap: the protocol gives you the header and auto-reconnect, not the event log. If you cannot store and replay events (a Redis stream, a database table, a Kafka offset, or similar), clients will see gaps after every proxy timeout or deploy.

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.

TechniqueDirectionTypical latencyOverheadComplexity
Short pollingClient → server repeatHigher (interval-bound)Many idle requestsLowest
Long pollingClient → server (held)MediumFewer requests than short pollLow–medium
SSEServer → client streamLowOne long responseLow
WebSocketBidirectionalLowestStateful connectionsHighest

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 caseBest techniqueWhy
Email inbox refreshShort pollingLow frequency, simple
Chat (large social app, 2008 era)Long pollingServer→client, works everywhere
Auction / live bid pricesSSEServer→client push; WebSocket usually overkill
Live sports scoresSSEServer→client push, auto-reconnect
Stock tickerSSEContinuous server updates
Chat (modern)WebSocketBidirectional, high frequency
Multiplayer gamingWebSocketLow latency bidirectional
Live dashboardSSEServer pushes metric updates
NotificationsSSE or long pollingInfrequent server→client events
Collaborative editingWebSocketBidirectional conflict resolution
IoT device commandsLong polling or MQTT-styleSimple 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.

1Message arrives while waiting
Client→GET /poll→Server

Server accepts the connection and holds it: no bytes yet, until data is ready or an internal timer fires.

Server→200 OK + JSON→Client
2Immediately poll again

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.

3Timeout with nothing new

No payload arrives within the agreed window (about 30s in the sketch), so the server closes the wait with an empty success.

Server→204 No Content→Client

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.

python
# 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.

http
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 forever

SSE 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.

plaintext
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 boundary

SSE 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

javascript
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)

javascript
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
# 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)

javascript
// 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

http
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.

javascript
// 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

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.