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

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

  1. 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=18

    Exit 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.

  2. 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=18

    The 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.

  3. 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_ENCODING

    The 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=0 is CONNECTING, 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  #6

    The 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: 1000 the server declared.

  4. 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  #10

    The browser still sends Last-Event-ID: 3 every 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.

  5. 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_ENCODING

    Ids 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  #13

    Drop 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

Backoff on loopback: 1.005, 1.008 and 1.006 s under retry: 1000, then 4.003 and 4.005 s under retry: 4000 Source: Server terminal in steps 3 and 5, captured 2026-09-12

Common mistakes

Sign: The reconnect test passes and events still go missing in production.Cause: Reconnecting and resuming are two different properties. In step 4 the browser sent Last-Event-ID: 3 on every attempt and the server answered with ids 1, 2, 3 each time. The client logged no error, only duplicates, and any consumer without de-duplication of its own counted every event twice.
Sign: A retry loop is written by hand because the stream seems not to come back.Cause: EventSource retries on its own after a transport error and stops only after close(). An onerror handler that opens a second EventSource doubles the connections the server holds, and the server log is where that shows up first. The tell is readyState: 0 means the browser is already reconnecting, 2 means it has given up.
Sign: curl exit 18 is filed as a curl problem.Cause: Exit 18 is CURLE_PARTIAL_FILE, and on a chunked response it means the body ended without its terminating chunk. That is the signature of a dropped stream, so in a script it is how you tell a drop from an orderly close, which exits 0.
Sign: The reconnect rate in the test does not match production.Cause: The retry: field is sent by the server and replaces the browser default for that endpoint. A staging server sending retry: 1000 and a production server sending nothing reconnect at different rates, so a mass reconnect that staging absorbed arrives at production on a different schedule entirely.

What to check next

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.

intermediate10 minpublished updated Maks Verny