Skip to content
advanced Phase 7 · Subscriptions and Realtime Delivery

GraphQL over Server-Sent Events

Delivering subscriptions through HTTP server-sent events when WebSockets are blocked.

45m
1 problems
Topic Progress 0%

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: 3000 tells the browser how long to wait before reconnecting after a drop.
  • id: is remembered by the client and replayed as Last-Event-ID on the next connection — that pair is the resume hook.
  • event: names the record type. The default name is message, so a bare data: line is an ordinary event.
  • data: carries one JSON document. Several data: 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.
  • : ping is 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

0 / 1 solved
Write the SSE Subscription Endpoint
Server-Sent Events Stream

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?

Question 1 options

2. An SSE stream drops, the client reconnects, and three events are delivered twice. What is the correct fix?

Question 2 options

3. How does a client stop an SSE subscription?

Question 3 options

Flashcards

Question

Why choose SSE over WebSocket

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

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

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

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

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.

Continue Learning