Eventsource auto reconnect
Drop the stream and watch the browser come back on its own. Chrome reopened /stream about 1.006 seconds after each drop and sent Last-Event-ID: 3 as a request header. Replay that header by hand with curl -sN -H 'Last-Event-ID: 3' http://127.0.0.1:8732/stream and read which id the server answers with.
Why check this
A stream that never drops does not exist. Load balancers recycle connections, phones change network, and a deploy closes every socket at once. The reconnect is part of the feature, so it is part of the test. Run this before a realtime release and after any change to the gateway in front of the stream.
The failure it prevents is a silent hole in the data. The browser reconnects, the user sees a live feed again, and the events written in between were never delivered.
Prerequisites
- Node 22. Save the server below as
reconnect-server.mjsand start it withnode reconnect-server.mjs. It listens on127.0.0.1:8732and destroys every connection after three events, which is the condition this whole check needs. - Two routes of the same shape.
/streamreads theLast-Event-IDrequest header and continues from the next id./noresumeignores the header and starts at 1 every time. - The server prints one line per connection on stdout. Keep that terminal visible, because half the evidence is on the server side.
- curl with
-N, and Chrome or another Chromium browser. Node 22 has noEventSource, so the automatic half of this behaviour has no command-line client here. - The timings are loopback, captured once on 2026-09-12. A backoff measured across a real network also carries a handshake these numbers do not.
import { createServer } from 'node:http';
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const at = () => new Date().toISOString().slice(11, 23);
let conn = 0;
createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:8732');
if (url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<!doctype html><meta charset="utf-8"><title>sse reconnect</title>');
return;
}
if (url.pathname !== '/stream' && url.pathname !== '/noresume') {
res.writeHead(404).end();
return;
}
const sent = req.headers['last-event-id'];
const from = url.pathname === '/stream' && sent ? Number(sent) + 1 : 1;
const n = ++conn;
console.log(`${at()} open #${n} ${url.pathname} Last-Event-ID: ${sent ?? '(none)'} -> ids ${from}..${from + 2}`);
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' });
res.write(`retry: ${url.searchParams.get('retry') ?? 1000}\n\n`);
for (let i = 0; i < 3; i += 1) {
res.write(`id: ${from + i}\ndata: {"n":${from + i}}\n\n`);
await wait(200);
}
console.log(`${at()} drop #${n}`);
res.socket.destroy();
}).listen(8732, '127.0.0.1', () => console.log(`${at()} listening on 127.0.0.1:8732`));
Steps
- Step 1.
Open the stream with no history and let it drop.
curl -sN http://127.0.0.1:8732/stream; echo "curl exit=$?"retry: 1000 id: 1 data: {"n":1} id: 2 data: {"n":2} id: 3 data: {"n":3} curl exit=18Exit 18 is
CURLE_PARTIAL_FILE. The server destroyed the socket without writing the terminating chunk, which is what a dropped stream looks like from the client. A clean end gives exit 0. - Step 2.
Send the header a browser would send, carrying the last id you saw.
curl -sN -H 'Last-Event-ID: 3' http://127.0.0.1:8732/stream; echo "curl exit=$?"retry: 1000 id: 4 data: {"n":4} id: 5 data: {"n":5} id: 6 data: {"n":6} curl exit=18The server answered with 4, 5, 6 rather than 1, 2, 3. That single header is the whole resume contract, and this command tests it without a browser.
- Step 3.
Open
http://127.0.0.1:8732/in Chrome, open DevTools, Console tab, and paste this. Leave it for six seconds.const t0 = performance.now(); const es = new EventSource('/stream?retry=1000'); es.onmessage = (e) => console.log(`${((performance.now() - t0) / 1000).toFixed(3)} message id=${e.lastEventId} ${e.data}`); es.onerror = () => console.log(`${((performance.now() - t0) / 1000).toFixed(3)} error readyState=${es.readyState}`);error: Failed to load resource: the server responded with a status of 404 (Not Found) 0.005 message id=1 {"n":1} 0.212 message id=2 {"n":2} 0.420 message id=3 {"n":3} 0.633 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING 1.639 message id=4 {"n":4} 1.843 message id=5 {"n":5} 2.048 message id=6 {"n":6} 2.260 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING 3.268 message id=7 {"n":7} 3.482 message id=8 {"n":8} 3.696 message id=9 {"n":9} 3.901 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING 4.908 message id=10 {"n":10} 5.116 message id=11 {"n":11} 5.322 message id=12 {"n":12} 5.522 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODINGThe capture labels each console entry with its type; DevTools shows the same text in red with no prefix. The first line is the favicon the browser asks for on its own. Ids run 1 to 12 with no gap and no repeat across four connections.
readyState=0isCONNECTING, so that handler firing is the browser announcing the next attempt, not a failure.The server terminal covering the same six seconds:
07:32:15.184 open #3 /stream Last-Event-ID: (none) -> ids 1..3 07:32:15.814 drop #3 07:32:16.819 open #4 /stream Last-Event-ID: 3 -> ids 4..6 07:32:17.440 drop #4 07:32:18.448 open #5 /stream Last-Event-ID: 6 -> ids 7..9 07:32:19.082 drop #5 07:32:20.088 open #6 /stream Last-Event-ID: 9 -> ids 10..12 07:32:20.703 drop #6The browser sent the header by itself, and its value tracked the last id received: none, then 3, then 6, then 9. Drop to next open measures 1.005, 1.008 and 1.006 seconds against the
retry: 1000the server declared. - Step 4.
Point the same client at the route that ignores the header.
new EventSource('/noresume?retry=1000');error: Failed to load resource: the server responded with a status of 404 (Not Found) 0.003 message id=1 {"n":1} 0.206 message id=2 {"n":2} 0.415 message id=3 {"n":3} 0.630 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING 1.635 message id=1 {"n":1} 1.839 message id=2 {"n":2} 2.042 message id=3 {"n":3} 2.246 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING 3.259 message id=1 {"n":1} 3.473 message id=2 {"n":2} 3.686 message id=3 {"n":3} 3.887 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING 4.899 message id=1 {"n":1}Ids 1, 2, 3 arrive over and over: the reconnect works and the resume does not. The server terminal for the same run:
07:32:23.219 open #7 /noresume Last-Event-ID: (none) -> ids 1..3 07:32:23.846 drop #7 07:32:24.852 open #8 /noresume Last-Event-ID: 3 -> ids 1..3 07:32:25.462 drop #8 07:32:26.474 open #9 /noresume Last-Event-ID: 3 -> ids 1..3 07:32:27.103 drop #9 07:32:28.115 open #10 /noresume Last-Event-ID: 3 -> ids 1..3 07:32:28.738 drop #10The browser still sends
Last-Event-ID: 3every time. The value never rises past 3 because the client never receives a higher id, so the header is a perfect record of a server that reads nothing. - Step 5.
Ask the server for a four second backoff and measure it.
new EventSource('/stream?retry=4000');error: Failed to load resource: the server responded with a status of 404 (Not Found) 0.005 message id=1 {"n":1} 0.205 message id=2 {"n":2} 0.410 message id=3 {"n":3} 0.612 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING 4.616 message id=4 {"n":4} 4.818 message id=5 {"n":5} 5.035 message id=6 {"n":6} 5.245 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING 9.249 message id=7 {"n":7} 9.464 message id=8 {"n":8} 9.664 message id=9 {"n":9} 9.871 error readyState=0 error: Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODINGIds still run unbroken, at a quarter of the rate. The server terminal:
07:32:30.067 open #11 /stream Last-Event-ID: (none) -> ids 1..3 07:32:30.675 drop #11 07:32:34.678 open #12 /stream Last-Event-ID: 3 -> ids 4..6 07:32:35.307 drop #12 07:32:39.312 open #13 /stream Last-Event-ID: 6 -> ids 7..9 07:32:39.933 drop #13Drop to next open is 4.003 and 4.005 seconds. One line from the server changed the reconnect rate of a client nobody edited.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Last-Event-ID present and rising | The browser is resuming correctly | Confirm the server uses it, as in step 3. |
| Last-Event-ID present, ids restart at 1 | The server ignores the header | Every reconnect replays old events. Fix the server, not the client. |
| No Last-Event-ID on a reconnect | The server never sent an id: field | Add ids, or accept that a drop loses whatever was in flight. |
| Drop to reopen equals the retry value | The retry: field is being honoured | Set it to what the endpoint can absorb on a mass reconnect. |
| readyState=0 inside onerror | Chrome is already reconnecting | Add no retry of your own. |
| curl exit 18 | The stream was cut, not closed | Expected here. Exit 0 would mean an orderly end. |
Thresholds
Common mistakes
What to check next
- How to test server sent events: the wire format these ids and retry values live in.
- How to test websocket reconnect: the same question where nothing reconnects for you.
- Websocket close code 1006: how a dropped socket reports itself when there is no HTTP status to read.
- Long polling test: the transport with no reconnect story, because every response is already a new request.
FAQ
Does EventSource reconnect by itself?
Yes, after any transport error, with no code from you. In step 3 one EventSource produced four connections in six seconds. It stops when you call close().
How do I change the delay before the browser reconnects?
Send a retry: line with a value in milliseconds on the stream. The server terminal measured 1.006 seconds of backoff under retry: 1000 and 4.005 seconds under retry: 4000, so the field takes effect from the next connection.
Which side sends Last-Event-ID?
The browser, as a request header, on every reconnect, carrying the last id: it received. The server has to read it and continue from there. Nothing in the browser checks whether it did, which is what step 4 shows.
How do I stop the retries during a test?
Call close() on the EventSource. Until then a forgotten tab keeps reconnecting: the server terminal in step 4 records a new connection every second for as long as the client lived.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny