When the WebSocket Upgrade Never Comes
When the WebSocket Upgrade Never Comes
WebSocket traffic starts as an ordinary HTTP request carrying an Upgrade: websocket header, and anything in the path that does not understand upgrades can refuse it: corporate forward proxies, some mobile carriers, older load balancers, TLS-terminating middleboxes configured for plain HTTP. The client sees a connection that fails before GraphQL is ever involved, and no amount of resolver debugging helps.
Server-Sent Events skip the negotiation. The response is a normal 200 with Content-Type: text/event-stream, so it travels through the same infrastructure as a REST response, survives HTTP/1.1 keep-alive, and rides an existing connection under HTTP/2.
| Polling | SSE | WebSocket | |
|---|---|---|---|
| Direction | client pulls | server pushes | bidirectional |
| Handshake | none | none, plain HTTP | Upgrade header |
| Survives strict proxies | yes | usually | often no |
| Automatic reconnect | no | built into EventSource |
you write it |
| Time to first update | up to one interval | immediate | immediate |
| Client-to-server messages | new request | second HTTP request | same socket |
The last two rows carry the trade. SSE is unidirectional by construction: the stream carries server-to-client bytes only, so a client that wants to stop a subscription must open a second HTTP request. And while EventSource reconnects by itself, that automatic behavior is also how events get missed or delivered twice — covered below.
Reach for SSE when subscriptions must work behind restrictive proxies or when you want reconnect logic you did not write. Reach for WebSockets when the client also sends frequent messages over the same channel — typing indicators, bidirectional editing — and you control the network path. Polling remains the honest answer when updates are rare; see realtime feature patterns for that decision in full.
Frames on the Wire
Frames on the Wire
The transport opens one long-lived response and writes newline-delimited records into it:
POST /graphql/stream HTTP/1.1
Content-Type: application/json
{"query":"subscription { messageAdded { id body } }"}
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
retry: 3000
id: 1712000001
event: next
data: {"data":{"messageAdded":{"id":"m1","body":"hi"}}}
id: 1712000002
event: next
data: {"data":{"messageAdded":{"id":"m2","body":"there"}}}
: ping
event: complete
data:
Read the records line by line:
retry: 3000tells the browser how long to wait before reconnecting after a drop.id:is remembered by the client and replayed asLast-Event-IDon the next connection — that pair is the resume hook.event:names the record type. The default name ismessage, so a baredata:line is an ordinary event.data:carries one JSON document. Severaldata:lines in one record are joined with newlines before parsing, which is why generators write arrays as one line rather than one line per element.: pingis a comment. It resets proxy idle timers and never reaches application code — the keepalive.
The server side is small:
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const keepalive = setInterval(() => res.write(': ping\n\n'), 15000);
const send = (id, type, payload) =>
res.write('id: ' + id + '\nevent: ' + type + '\ndata: ' + JSON.stringify(payload) + '\n\n');
const teardown = () => {
clearInterval(keepalive);
iterator.return && iterator.return();
res.end();
};
req.on('close', teardown);
Two lines deserve a pause. req.on('close', teardown) is the only cancellation signal SSE gives you — there is no unsubscribe message on the stream — and the keepalive comment is what stops a proxy from declaring the connection idle during a quiet period.
One transport caveat: raw EventSource only issues GET requests and cannot set headers, so subscriptions that need a POST body or an Authorization header use a fetch-based SSE client such as graphql-sse instead of the browser built-in. The frame format above stays the same either way.
Reconnection, Duplicates, and Cancellation
Reconnection, Duplicates, and Cancellation
EventSource reconnects on its own. It also drops everything that happened while it was away, unless the server cooperates.
The gap problem
t0 client receives id 1712000005
t1 connection drops; server emits events 6, 7, 8
t2 client reconnects with Last-Event-ID: 1712000005
t3 server replays 6, 7, 8 from its buffer, then continues live
Without the id / Last-Event-ID pair, t2 reconnects as a brand-new subscriber and events 6-8 are gone. Buffers are bounded, so a client absent longer than the buffer window must be told the gap is too large and to refetch state with a normal query — pretending the stream was continuous is how clients end up rendering stale data with confidence.
Duplicates are the price of resumption
Replay is not exactly-once: a client that received event 7 but crashed before persisting it receives 7 again. Make handlers idempotent — key on the event id, ignore anything at or below the highest stored — instead of chasing exactly-once from the transport.
Stopping requires a second request
SSE carries no client-to-server message, so cancellation is a separate HTTP call. With a fetch-based SSE client that means disposing the stream:
const client = createClient({ url: 'https://api.example.com/graphql/stream' });
const abort = new AbortController();
const stop = client.subscribe(
{ query: SUBSCRIBE_MESSAGES },
{ next: render, error: report, complete: done },
);
// later, when the user navigates away:
abort.abort();
stop(); // issues the protocol stop request on its own connection
From the server's perspective, cancellation arrives either as req.on('close') or as a dedicated stop request, depending on the protocol variant — and it must trigger the same iterator teardown a WebSocket complete would. If the only cleanup path is "the client sent a polite unsubscribe," quiet proxies and closed laptops will leak your timers.
Choosing between them: if your environment blocks WebSocket upgrades, or you want browser-native reconnect, take SSE and pay for the second cancellation request plus duplicate handling. If you need frequent client-to-server messages or binary frames, take WebSockets and pay for the handshake, keepalive, and reconnect logic you now own.
Practice Problems
Implement the HTTP handler that turns a subscription operation into an event-stream response: correct headers, one record per event with an id and event type, a keepalive during quiet periods, and teardown when the client goes away. The stream must carry enough information for the client to resume after a drop.
Example:
Input: POST {"query":"subscription { messageAdded { id } }"} with a valid token
Output: 200 OK with content-type text/event-stream, then records: "id: 1712000001\nevent: next\ndata: {...}\n\n"
Each record is self-describing: id becomes Last-Event-ID on the next connection, event names the record type, and the blank line terminates the record so the client can dispatch it.
Optimal Solution — O(1) per event time, O(1) plus O(k) for k buffered records space
Write the stream headers once, assign a sequential id per event, emit comment-line keepalives on an interval, and register teardown on the request close event — the only cancellation signal SSE provides.
app.post('/graphql/stream', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
let seq = 0;
const write = (event, payload) => {
res.write('id: ' + ++seq + '\nevent: ' + event + '\ndata: ' + JSON.stringify(payload) + '\n\n');
};
write('next', { data: { messageAdded: { id: 'm1' } } });
const keepalive = setInterval(() => res.write(': ping\n\n'), 15000);
const iterator = subscribeToTopic('MESSAGE_ADDED');
const teardown = () => {
clearInterval(keepalive);
iterator.return && iterator.return();
res.end();
};
req.on('close', teardown);
for await (const payload of iterator) write('next', { data: payload });
write('complete', {});
teardown();
}); Edge Cases:
- Client disconnects while no events are flowing
- Buffered writer fills because the consumer stopped reading
- Reconnect arrives with a Last-Event-ID older than the buffer
Quiz
1. Why does SSE usually pass corporate proxies that block WebSockets?
2. An SSE stream drops, the client reconnects, and three events are delivered twice. What is the correct fix?
3. How does a client stop an SSE subscription?
Flashcards
Question
Why choose SSE over WebSocket
Click to reveal answer
Answer
No upgrade handshake means SSE traverses proxies and load balancers that refuse WebSockets, and the browser reconnects on its own.
Question
SSE frame fields that matter
Click to reveal answer
Answer
id (resume cursor), event (record type, default message), data (JSON payload), retry (reconnect delay), and colon-prefixed comments as keepalives.
Question
Last-Event-ID round trip
Click to reveal answer
Answer
On reconnect the client sends the last id it saw; the server replays buffered events after that id or reports the gap as too large.
Question
Why SSE consumers must be idempotent
Click to reveal answer
Answer
Replay after a drop can resend events the client already handled, so deduplicate by event id before applying.
Question
How cancellation works on SSE
Click to reveal answer
Answer
The stream is unidirectional, so the client issues a second request or disposes the stream; the server maps that to iterator teardown.
Revision Notes
Key Takeaways
- 1. SSE trades bidirectional traffic for proxy friendliness and browser-native reconnect
- 2. id plus Last-Event-ID is the resume contract; without it, a reconnect silently loses events
- 3. Replay means at-least-once, so consumers deduplicate by id instead of trusting the transport
- 4. Cancellation is a second request, and teardown must also trigger on the connection closing
Interview Tips
- • Compare the three transports on direction, handshake, reconnect, and failure modes rather than on preference
- • Explain the gap-versus-duplicate tension: resume fixes loss and creates duplicates, so the consumer owns idempotency
- • State which cancellation mechanism your server implements and how teardown runs when a laptop simply closes the lid
Cheat Sheet
GraphQL over SSE
Response headers
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Record anatomy
retry: 3000 -> reconnect delay after a drop
id: 42 -> replay cursor sent back as Last-Event-ID
event: next -> record type (default: message)
data: {...json...} -> one payload; multiple data lines join with newlines
: ping -> comment; resets proxy idle timers, never reaches app code
Rules
- Reconnect = new request; supply Last-Event-ID or the gap is lost.
- Replay is at-least-once; dedupe by id in the consumer.
- Cancel with a second request or stream disposal; also handle req close.
- Keepalives every 15 s beat typical 60 s proxy idle timeouts.