Skip to content
academia.sh

Lesson 22 / 25

Server-Sent Events

Events written one after another onto a single open response body: the line format of the event stream, the header conventions of a streaming response, reconnection by event ID, and the function of the heartbeat line against intermediaries.

Contents

Long polling held the connection and closed it after writing a single response. A reservation queue position changing five times meant five separate connections: for every event the client reconnected, the server re-authorized, and the intermediaries re-routed.

Yet the first connection was already open. An HTTP response body does not have to be written all at once; it can be kept open and written to in pieces. Server-Sent Events (SSE) standardizes this: a one-way, text-based stream from server to client that carries more than one event.

Format

The stream’s body is line-based and marked with the text/event-stream content type. An event consists of field lines and ends with a blank line. Five fields are defined.

data: is the event’s body and the only required field; multiple data: lines are joined with line breaks. event: gives the event’s type; the client can attach different handlers for different types. id: is the event’s identifier and marks where the stream left off. retry: tells the client how many milliseconds to wait after a disconnect. A line starting with a colon is a comment; it produces no event and is used as a heartbeat.

The entire format is plain text, so the stream can be read directly with curl. This is SSE’s most practical aspect for debugging.

Server

The server below publishes a member’s position in a reservation queue: five steps of progress and a closing event announcing that the book is ready.

// sse.mjs — publishes reservation queue progress via server-sent events
import { createServer } from "node:http";

// The member's position in the reservation queue; the last event announces that the request has been fulfilled.
const EVENTS = [
  { id: 1, type: "queue", data: { position: 5, eta_min: 12 } },
  { id: 2, type: "queue", data: { position: 4, eta_min: 9 } },
  { id: 3, type: "queue", data: { position: 3, eta_min: 7 } },
  { id: 4, type: "queue", data: { position: 2, eta_min: 4 } },
  { id: 5, type: "queue", data: { position: 1, eta_min: 2 } },
  { id: 6, type: "ready", data: { book: "The Disconnected", branch: 3 } },
];

const counter = { connection: 0, event: 0 };
const wait = (ms) => new Promise((c) => setTimeout(c, ms));

createServer(async (request, response) => {
  response.sendDate = false;
  const url = new URL(request.url, "http://local");

  if (url.pathname === "/metrics") {
    response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return response.end(JSON.stringify(counter) + "\n");
  }
  if (url.pathname !== "/stream") return response.writeHead(404).end();

  counter.connection++;
  // Streaming response: the body never ends, so no length is reported and
  // intermediaries' response buffering is disabled.
  response.writeHead(200, {
    "content-type": "text/event-stream; charset=utf-8",
    "cache-control": "no-store",
    "x-accel-buffering": "no",
  });

  // The client reports where it left off via the Last-Event-ID header; otherwise it starts from the beginning.
  const lastEventId = Number(request.headers["last-event-id"] ?? 0);
  const cut = Number(url.searchParams.get("cut") ?? 0);

  response.write("retry: 2000\n\n");                // reconnection interval (ms)
  response.write(": heartbeat\n\n");                 // comment line: keeps the connection alive

  let sent = 0;
  for (const e of EVENTS.filter((e) => e.id > lastEventId)) {
    if (cut && sent === cut) break;                 // simulates a connection drop
    await wait(50);
    if (response.writableEnded) return;              // client disconnected
    response.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`);
    counter.event++; sent++;
  }
  response.end();
}).listen(8371, "127.0.0.1", () => console.log("listening: 127.0.0.1:8371"));

Reading the Stream

The curl -N option disables output buffering and shows the stream as it arrives. The script below first cuts the stream off after three events, then resumes it from where it left off. Port 8371 is arbitrary and must be free.

#!/usr/bin/env bash
# Starts sse.mjs, reads the stream with an interruption, resumes with Last-Event-ID, stops it.
node sse.mjs > /dev/null & server=$!
sleep 1
A=http://127.0.0.1:8371

echo "--- first connection: server cuts off after three events ---"
curl -sN "$A/stream?cut=3"
echo "--- reconnection: resuming from where it left off with Last-Event-ID ---"
curl -sN -H 'Last-Event-ID: 3' "$A/stream"
echo "--- metrics ---"
curl -sS "$A/metrics"
kill "$server"
--- first connection: server cuts off after three events ---
retry: 2000

: heartbeat

id: 1
event: queue
data: {"position":5,"eta_min":12}

id: 2
event: queue
data: {"position":4,"eta_min":9}

id: 3
event: queue
data: {"position":3,"eta_min":7}

--- reconnection: resuming from where it left off with Last-Event-ID ---
retry: 2000

: heartbeat

id: 4
event: queue
data: {"position":2,"eta_min":4}

id: 5
event: queue
data: {"position":1,"eta_min":2}

id: 6
event: ready
data: {"book":"The Disconnected","branch":3}

--- metrics ---
{"connection":2,"event":6}

The metrics line shows the actual gain: six events, two connections. Without the interruption, a single connection would have sufficed. Carrying the same six events with long polling would have required six connections, six authorizations, and six routing decisions.

Reconnection

The stream breaks sooner or later: the server is redeployed, an intermediary times out, the network drops. What distinguishes SSE from long polling is that a disconnect is a condition the protocol expects.

Two fields govern this behavior. The duration announced with retry: sets how long the client waits after a disconnect; the server can vary this value with the state of the load, and so manage a reconnection storm. The identifier announced with id: is stored on the client and sent back as the Last-Event-ID header on the reconnection request. The server reads this header and resumes the stream from there.

The browser-side EventSource interface does both on its own: it detects the disconnect, waits for the retry duration, and puts the last ID it saw into the header. The server’s only responsibility is making sure that ID is a meaningful resume point.

This creates a requirement on the server side. Resuming by identifier requires events to be kept for a while; if the server knows only the “current state,” events produced during a disconnect are lost. Two solutions exist in practice: a ring buffer holding the last N events, or sending the entire state instead of an event. The second makes the identifier unnecessary, because every event is sufficient on its own; in exchange, every event is larger.

Rules of a Streaming Response

A streaming response differs from an ordinary response in four points, and three of these differences show up in the headers.

The body’s length is not reported, because it is not known; Content-Length cannot be written. The response must not be cached; Cache-Control: no-store says this, otherwise an intermediate cache would try to buffer the stream and deliver it as a single piece. Intermediaries’ response buffering must be disabled; the code does this with the X-Accel-Buffering header, which is not a standard header but is a widely recognized directive. The fourth difference is in the code: before every write, whether the client has disconnected is checked, otherwise the server keeps writing to a closed socket.

This is also where the heartbeat line’s function comes from. Intermediaries can treat a connection with no data flowing for a long time as dead and close it. A comment line sent at regular intervals — : heartbeat, which produces no event — keeps the connection alive and has no side effect on the client side.

Limits

SSE is one-way. The client cannot send data to the server; it must open a separate HTTP request for that. This is sufficient for tracking a reservation queue, but not for an arrangement where both sides are constantly talking.

The second limit is in the transport layer. Over HTTP/1.1, the number of concurrent connections browsers open to the same origin is limited, and a stream that stays open permanently holds on to one of that share; a user with several tabs open can exhaust the limit. Over HTTP/2, streams are multiplexed onto a single connection, so this limit disappears.

The third limit is the data format: the stream is text, binary data cannot be carried. If binary data is needed, it must be encoded, and that inflates the size.

Summary

  • Server-sent events form a one-way, text-based stream from server to client, written one after another onto a single HTTP response body that is kept open.
  • An event consists of the data, event, id, and retry fields and ends with a blank line; a comment line starting with a colon is used as a heartbeat.
  • In the measurement, six events were carried over two connections; the same events would have required six connections with long polling.
  • A disconnect is a condition the protocol expects: the client waits for the retry duration and sends the last ID it saw with the Last-Event-ID header, and the server resumes the stream from there.
  • A streaming response does not report its length, must not be cached, must have intermediary buffering disabled, and must check whether the client is still connected before every write.

Next Step

As long as the stream is one-way, it is sufficient for a reservation queue. But the library service has cases where both sides talk: a clerk correcting a branch stock counter, a member withdrawing from the queue, several clerks editing the same loan record at the same time. Opening a separate HTTP request for each of these gives back the latency the stream gained. The next lesson covers how a two-way connection is set up: a connection that starts over HTTP, switches to its own frame format after a handshake, and carries messages in both directions.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close