Skip to content

Real-time web / Field guide

EventSource Reconnects: Last-Event-ID and the Events You Missed

By ·

The connection dropped, EventSource reconnected, the page looks alive. Whether it is still correct depends on what your server did with one request header.

Server-Sent Events have a reputation for being the easy real-time option, and for the happy path they are. The browser API is one line, the wire format is plain text, and reconnection is built in. This guide is about the unhappy path: what happens between the moment a stream breaks and the moment it is back, which events can disappear in that gap, and what the server has to do so the page does not quietly show stale data. If you are new to the protocol, Server-Sent Events explained covers the basics first.

What EventSource does by itself

Every event on an SSE stream can carry an id line. The browser remembers the last id it received, and a retry line tells it how long to wait before reconnecting:

retry: 3000

id: 41
event: order.created
data: {"id": 9120, "total": 74.50}

id: 42
event: order.paid
data: {"id": 9120}

When the connection breaks after it was established, EventSource switches its readyState to CONNECTING, waits for the retry delay, and sends a new request to the same URL with one extra header:

GET /stream HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 42

That is the whole built-in mechanism. It keeps trying for as long as the page lives, and it stops in only a few cases:

  • your code calls close();
  • the server answers a reconnect with 204 No Content, which is the polite way to say "do not come back";
  • the server answers with an error status or a content type other than text/event-stream, and the connection fails for good.

The last case surprises people: a load balancer returning 502 during a deploy can end the stream permanently, with readyState stuck at CLOSED and no further attempts. A page that cares about staying live has to watch for that and open a new EventSource itself.

What it leaves to you

The header names the last event the browser saw. It does not bring anything back. Three things are your job:

  • Keeping history. If the server forgot event 43 the moment it wrote it, Last-Event-ID: 42 is a pointer into nothing.
  • Carrying the id across new connections. Only the browser's own reconnect of the same object sends the header. A new EventSource, for example one your code opens after the old one failed or after a hidden tab comes back, starts with no last id at all. If you want replay there too, store the id yourself and send it, usually as a query parameter, because the browser API cannot set custom headers.
  • Deciding what a reconnect means. Replay the missed events, send a fresh snapshot, or tell the page its state is gone. The browser does not know which of these your screen needs.

Reconnecting is not recovering. EventSource gives you a new connection and a number. Recovery is server logic: retained events, replay, and a defined answer for "I no longer have what you missed".

Where events get lost

It is tempting to think events are lost only while the connection is visibly down. In practice there are three gaps, and the first one is invisible.

Writes into a connection that is already dead

A laptop lid closes, a phone switches from Wi-Fi to mobile data, a NAT box drops its mapping. The server is not told. Its next write goes into the operating system's send buffer and returns success. So does the one after it. The server only finds out the peer is gone when a later write finally fails, often on the next heartbeat. Every event "successfully" written in between went nowhere, and nothing re-queues it.

The time between two connections

Between the failure and the reconnect, new events are published to a connection that does not exist yet. Three seconds of retry delay on a busy feed is several updates.

Closes nobody planned to be visible

Proxies close idle connections. Deploys restart workers. Many servers, ours included, cap how long one connection may live. Each of these ends a stream that was working fine, and each one is a reconnect your recovery logic has to survive.

Server side: number, keep, replay

The fix has the same shape in any language:

  1. Number every data frame. Make the id unique across connections, not just within one. If every connection counts from 1, a reconnect that says "I got 5" is ambiguous. A per-connection prefix plus a counter is enough.
  2. Keep what you wrote. After a frame is written, append it to a bounded per-session buffer. Bound it by count and by age, because a tab that comes back after an hour should not get an hour of frames.
  3. Replay after the named id. On reconnect, find the id the client named and send every frame after it, in the original order, before any new frame.
  4. Say so when you cannot. If the id is no longer in the buffer, the gap is unknown. Send an explicit reset event, and let the page reload its state from a fresh snapshot. Never resume silently from "now".

In plain PHP with Redis, the buffer is a capped list:

// After a frame was written to the stream:
$key = "sse:ring:$sessionId";
$redis->rPush($key, json_encode(['id' => $id, 'event' => $event, 'data' => $data]));
$redis->lTrim($key, -128, -1);   // keep the last 128 frames
$redis->expire($key, 600);       // and only for ten minutes

// When a client connects:
$lastId = $_SERVER['HTTP_LAST_EVENT_ID'] ?? $_GET['last_event_id'] ?? null;

Storing the buffer outside the process matters as soon as you run more than one worker or server. The reconnect can land anywhere, and a buffer that lives only in the worker that wrote it is invisible to the one that receives the reconnect.

Replay is not always the right answer. For a table or a dashboard, sending the current view again is simpler and always correct, as we argued in SSE vs WebSockets. Replay pays off when the events themselves matter: a log, a chat, a sequence of steps, patches to a page that cannot be cheaply re-rendered. Most real systems need both: replay for short gaps, a snapshot when the gap is too long.

The closes you make yourself

Two server habits keep long-lived streams healthy, and both create reconnects on purpose.

Heartbeats

A comment line, any line starting with a colon, is ignored by EventSource but counts as traffic for every proxy on the way:

: ping

Send one every 15 to 30 seconds, and keep proxy idle timeouts above that interval. The heartbeat is also how a server discovers a dead peer sooner: the write that fails is often a heartbeat.

A maximum connection age

Closing a healthy connection sounds wrong, but a cap on connection age keeps load spread across workers, bounds per-connection memory, and moves clients onto new code after a deploy. When the server closes for that reason, it should say so in a final frame, and the client should treat it as "reconnect now", not "stop". Get that distinction wrong and the page goes quiet, which is exactly what happened to us.

Two reconnect bugs we shipped

On October 17, 2026, we noticed that the live "Say hello" counter in Just a Button? showed other visitors' greetings only after a reload. The stream was open, the server was publishing, and the page saw nothing. There were two separate causes.

An age-cap close read as a teardown

The server closes every long-lived stream after ten minutes with a final close event and the reason max_age. The client code treated every close as a deliberate shutdown: it closed the EventSource and dropped it. Pages that needed a live connection lost it exactly ten minutes after they opened. The fix was to reopen at once when the reason is max_age, and to treat the new connection as a reconnect, so every live component subscribes again.

A late teardown that ate the new connection

A tab in the background lost its connection about eight and a half minutes in. The browser reconnected with the same session, and the page re-subscribed straight away. Then the server finally noticed the old connection was dead, on its next failed heartbeat write, and ran its teardown. That teardown removed every subscription of the session, including the ones the new connection had just made. Our first workaround was a second re-subscribe thirty seconds after a reconnect. The real fix went into the server: a connection that was taken over by a newer one may only remove what it created before the takeover.

Neither bug was in the protocol. Both were in the code around it, and both looked like "the page works" until someone kept a tab open long enough. That is the honest summary of SSE reconnects: the transport part is solved, the recovery part is yours.

Inside Semitexa

Semitexa runs on Swoole and serves one shared SSE connection per page from the same application that renders it. The recovery logic described above lives in two packages.

Server: semitexa/ssr

  • ReplayingSseTransport is the one place every frame passes through, so it is the one place a frame can be numbered. Each data frame gets an id of the form k<connection>.<n>: a six-character tag for the connection and a counter. A frame is recorded only after the write succeeded; a frame that could not be written is re-queued and numbered again when it finally goes out. Lifecycle frames such as connected and close are not numbered.
  • SseReplayRing holds the last 128 frames of each session. When the application has a Redis pool, the ring is a Redis list with a ten-minute TTL, so a reconnect that lands on another Swoole worker still finds it. Without Redis it lives in the worker, and a reconnect elsewhere is told to reset. A Redis error is logged and the stream goes on: losing replay must never take a live stream down.
  • SseStreamRequest reads the last id from the Last-Event-ID header, or from a last_event_id query parameter for a page that opened a new EventSource.

On reconnect, the transport either replays or resets. This is the real method, with a dev-only timeline call removed and the event type enum written out as its value:

public function resume(mixed $stream, string $sessionId, ?string $lastEventId): array
{
    $this->bind($stream, $sessionId);
    if (!self::isReplayId($lastEventId)) {
        return [];
    }

    $missed = $this->ring->after($sessionId, (string) $lastEventId);
    if ($missed === null) {
        $this->inner->writeFrame($stream, SseFrame::fromResolved(null, 'ui.stream.reset', [
            '_type' => 'ui.stream.reset',
            'reason' => 'not_held',
        ]));

        return ['reset' => true];
    }
    $this->replay($stream, $missed);

    return ['replayed' => count($missed)];
}

The server also sends a heartbeat comment every 20 seconds and closes a held-open stream after SSE_MAX_CONNECTION_AGE_SECONDS, 600 by default, with a final close frame that names max_age. The fix for the second bug above is SseWorkerTables::supersededAt(): when a newer connection has taken over a session, the old connection's teardown removes only the rows numbered before the takeover.

Browser: semitexa/platform-ui

event-runtime.js handles the client half:

  • it remembers the last numbered id per stream URL and adds it as last_event_id whenever it opens a new EventSource for that URL;
  • on ui.stream.reset it announces the reset, and live feeds re-sync from fresh snapshots;
  • on every reconnect it re-posts each active subscription with the same id;
  • on a close with the reason max_age it reopens the live stream instead of treating it as the end.

None of this is something an application on Semitexa writes itself. A page with a live grid or a live counter gets numbered frames, replay, reset and re-subscription from the framework, and the application code only publishes what changed.

Production checklist

  • Every data frame has an id, unique across connections, and the id is assigned when the frame is actually written.
  • The server keeps a bounded buffer per session, by count and by age, in storage every worker can read.
  • A reconnect gets the missed frames in order, or an explicit reset when they are gone. Never a silent resume.
  • New EventSource objects carry the last id in the URL, because only the browser's own reconnect sends the header.
  • The client watches for CLOSED and opens a new connection itself after a fatal error, such as a 502 during a deploy.
  • Heartbeats run every 15 to 30 seconds, and proxy idle timeouts are longer than that.
  • Planned closes say why, and the client knows which reasons mean "come back".
  • Subscriptions are re-armed after a reconnect, and the old connection's cleanup cannot remove the new connection's state.
  • Someone keeps a tab open for an hour before the feature ships. Most reconnect bugs need time, not load, to appear.

Common questions

Does EventSource reconnect automatically?

Yes. After an established connection drops, it waits for the retry delay and reconnects to the same URL, sending Last-Event-ID with the last id it received. It does not reconnect after close(), after a 204 response, or after an error status or a wrong content type.

What does the Last-Event-ID header contain?

The value of the last id field the browser received on that EventSource. Events without an id leave it unchanged. The header only names the position; the server decides what to send from there.

Why did my page miss events even though it reconnected?

Usually because the server keeps no history, so the id points at nothing, or because the events were written into a connection that was already dead. Replay from a retained buffer, or send a fresh snapshot after every reconnect.

How long should the server keep events for replay?

Long enough to cover normal reconnects: a few minutes and a bounded number of frames per session is typical. Beyond that, a reset and a fresh snapshot are cheaper and safer than a long replay.

Can I set Last-Event-ID myself?

Not on a native EventSource: the API cannot set custom headers. Pass the last id as a query parameter when you open a new connection, and let the server read either.

← Back to the Semitexa Blog

Choosing between SSE and WebSockets in the first place? SSE vs WebSockets: How to Choose for Live Dashboards and Tables compares them for live screens.

The protocol basics, from the event format to EventSource: Server-Sent Events Explained: How SSE Works and When to Use It

Have a product idea?One free MVP every month