TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Server-Sent Events

The event format, EventSource, resuming with Last-Event-ID, and the reconnect loop.

Server-Sent Events are the simplest way for a server to push updates to a client. The client makes one ordinary GET request, and the server never quite finishes the response: it keeps writing events to it for as long as it has something to say. There's no special protocol and no upgrade, just HTTP and a text format. Browsers have a built-in client, EventSource, that also reconnects by itself, which is useful and, as this lesson shows, a trap if you don't handle the end of the stream.

The format

Here is a three-event stream from the streaming API. -N tells curl not to buffer, so events appear as they arrive:

Terminal
$ curl -i -N "https://api.testingapis.com/api/stream/sse?count=3&interval=300"

HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
x-accel-buffering: no
transfer-encoding: chunked

: connected, streaming events 1..3
retry: 3000

id: 1
event: message
data: {"id":1,"message":"Event 1 of 3","timestamp":"2026-10-11T12:35:02.882Z"}

id: 2
event: message
data: {"id":2,"message":"Event 2 of 3","timestamp":"2026-10-11T12:35:03.183Z"}

id: 3
event: message
data: {"id":3,"message":"Event 3 of 3","timestamp":"2026-10-11T12:35:03.483Z"}

event: done
data: {"total":3}
This example uses endpoints for signed-in members.Sign inCreate a free account

The content type is text/event-stream. Each event is a few field: value lines, and a blank line ends it. The fields:

LineMeaning
data:The payload. Several data lines in one event are joined with newlines
event:The event type. Without it the type is message
id:An ID the client remembers and sends back when it reconnects
retry:How many milliseconds the client should wait before reconnecting
A line starting with :A comment. Ignored by clients; often used as a heartbeat to keep connections alive

The headers matter as much as the body. cache-control: no-cache stops caches holding on to the stream, and x-accel-buffering: no tells nginx-style proxies not to buffer it. A proxy that buffers turns a stream into a long wait followed by everything at once, which is one of the most common SSE bugs and invisible when testing against a local server with no proxy in front.

Resuming with Last-Event-ID

When a connection drops, the client reconnects and sends the last ID it saw in a Last-Event-ID header. A server that supports it carries on from there:

Terminal
$ curl -N -H "Last-Event-ID: 2" "https://api.testingapis.com/api/stream/sse?count=4&interval=200"

: connected, streaming events 3..4
retry: 3000

id: 3
event: message
data: {"id":3,"message":"Event 3 of 4","timestamp":"2026-10-11T12:35:03.646Z"}

id: 4
event: message
data: {"id":4,"message":"Event 4 of 4","timestamp":"2026-10-11T12:35:03.846Z"}

event: done
data: {"total":4}
This example uses endpoints for signed-in members.Sign inCreate a free account

Asked to resume after event 2, the server started at 3. Nothing was repeated and nothing was missed, which is the whole point. Test it on any stream that matters: drop the connection mid-stream, reconnect with the last ID, and check the sequence is complete with no duplicates. A server that ignores the header and starts again from 1 forces every client to deduplicate.

EventSource and the reconnect loop

EventSource reconnects whenever the connection closes, waiting as long as retryasks. It does that whether the connection broke or the server finished on purpose; it can't tell the difference. Here is a client that listens for the stream's doneevent but doesn't act on it:

JavaScript
// sse.mjs. Browsers have EventSource built in; Node 24 needs: node --experimental-eventsource sse.mjs
const closeOnDone = process.argv[2] === "close";
const t0 = Date.now();
const log = (...a) => console.log(`+${((Date.now() - t0) / 1000).toFixed(1)}s`, ...a);

const es = new EventSource("https://api.testingapis.com/api/stream/sse?count=2&interval=200");
es.addEventListener("open", () => log("open"));
es.addEventListener("message", (e) => log("message", "lastEventId=" + e.lastEventId, e.data));
es.addEventListener("done", (e) => {
  log("done", e.data);
  if (closeOnDone) es.close();
});
es.addEventListener("error", () => log("error, readyState", es.readyState));

setTimeout(() => {
  log("stopping after 8 s");
  es.close();
}, 8000);
This example uses endpoints for signed-in members.Sign inCreate a free account
Output
$ node --experimental-eventsource sse.mjs
+0.2s open
+0.2s message lastEventId=1 {"id":1,"message":"Event 1 of 2","timestamp":"2026-10-11T12:35:18.212Z"}
+0.4s message lastEventId=2 {"id":2,"message":"Event 2 of 2","timestamp":"2026-10-11T12:35:18.413Z"}
+0.4s done {"total":2}
+0.4s error, readyState 0
+3.6s open
+3.6s done {"total":2}
+3.6s error, readyState 0
+6.6s open
+6.6s done {"total":2}
+6.6s error, readyState 0
+8.1s stopping after 8 s

After the stream ends, the client waits three seconds, reconnects with Last-Event-ID: 2, gets an immediate donebecause there's nothing after event 2, and does it all again, forever. Each loop is a real request to the server. Multiply by every open browser tab and a finished stream becomes a steady load that nobody intended. The fix is one line, closing the source when the stream says it's finished:

Output
$ node --experimental-eventsource sse.mjs close
+0.2s open
+0.2s message lastEventId=1 {"id":1,"message":"Event 1 of 2","timestamp":"2026-10-11T12:35:26.362Z"}
+0.4s message lastEventId=2 {"id":2,"message":"Event 2 of 2","timestamp":"2026-10-11T12:35:26.563Z"}
+0.4s done {"total":2}
+8.0s stopping after 8 s

Limits to plan for

  • EventSource can't set request headers, so there's no Authorization header. APIs use cookies or a token in the URL instead, with the same trade-offs as for WebSockets.
  • Over HTTP/1.1, browsers allow only six connections per site, and each open stream uses one. A user with several tabs open can run out. HTTP/2 lifts this, so check which your API is served over.
  • Streams are one way. For the client to talk back, it makes ordinary requests, or you need a WebSocket.

SSE test checklist

  • The content type is text/event-stream and events arrive one at a time, not all at the end.
  • IDs increase, and reconnecting with Last-Event-ID resumes without gaps or repeats.
  • The stream ends in a way the client recognises, and the client stops.
  • A run longer than the stream shows no reconnect loop.
  • Through every proxy and CDN in front of the API, not just against the bare server.

Exercise

Run the stock stream with curl -N "…/api/stream/sse/stock?count=5&interval=500"sign in to see and note the event type it uses. Then write an EventSource client that listens for that type, closes on done, and collects every tick. Then reconnect by hand with curl, sending Last-Event-ID: 2, and compare the IDs you get with what the basic stream at /api/stream/ssesign in to see does with the same header. The two streams behave differently. What would a client that relies on resuming have to do to cope with the stock stream?

Practise and go further