TL;DR β SSE reconnects are easy; recovering the events you missed is not. The fix is id + Last-Event-ID on the client and replay on the server. Below: the problem, the protocol, a minimal Node.js replay server, and a fetch-based client that resumes automatically.
Real-time applications depend on continuous data streams. Whether you're building live dashboards, order tracking systems, notification services, or AI-powered applications, Server-Sent Events (SSE) provide a simple way to push updates from the server to the client.
But there's one problem that is easy to overlook:
What happens when the connection drops?
Imagine you're monitoring an order:
Order created β Event 101
Payment confirmed β Event 102
Order processing β Event 103
Connection lost β β
Order shipped β Event 104
Order delivered β Event 105
When the client reconnects, how does it know what happened while it was offline?
Let's explore the problem, understand the SSE protocol, and implement a solution β with and without automatic SSE resume.
SSE is a one-way communication mechanism over HTTP. The server sends events to the client, and the connection stays open while events are being delivered.
A basic SSE event looks like this:
id: 101
event: order-updated
data: {"status":"created"}
The id field is important. It gives the event a unique identifier that can be used to resume a stream later.
However, receiving events over a persistent connection doesn't automatically mean your application can recover every event after a disconnection. There are two separate concerns:
The second one requires cooperation between the client and the server.
Let's start with a typical fetch-based streaming implementation (common when you need custom headers like Authorization, which native EventSource doesn't support):
const response = await fetch('/api/orders/stream', {
headers: {
Authorization: `Bearer ${token}`,
},
})
if (!response.body) {
throw new Error('ReadableStream is not supported')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
console.log(chunk)
}
This consumes a stream, but it provides no automatic reconnection and no event replay. If the connection drops, your application needs to implement:
You can build all of this yourself, but the complexity grows fast.
Last-Event-ID
Fortunately, SSE already defines a mechanism for resuming a stream: the Last-Event-ID header.
When an SSE event contains an ID:
id: 103
data: {"status":"processing"}
the browser's native EventSource remembers that ID. When the connection is interrupted and it automatically reconnects, it sends:
Last-Event-ID: 103
The server can then replay everything after event 103:
Client received: 101, 102, 103
Connection lost
Client reconnects with Last-Event-ID: 103
Server replays: 104, 105, 106
β οΈ This is the key distinction: the protocol gives you the identifier and the reconnect mechanism β your server must implement event storage and replay.
Here's a small, dependency-free example that stores recent events in memory and replays them based on Last-Event-ID:
import http from 'node:http'
const history = [] // [{ id, event, data }]
const clients = new Set()
let nextId = 1
const MAX_HISTORY = 1000
function format({ id, event, data }) {
return `id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`
}
function publish(event, data) {
const msg = { id: nextId++, event, data }
history.push(msg)
if (history.length > MAX_HISTORY) history.shift()
for (const res of clients) res.write(format(msg))
}
http.createServer((req, res) => {
if (req.url !== '/orders/stream') return res.writeHead(404).end()
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
res.write('retry: 3000\n\n') // suggested reconnect delay
// 1) Replay what the client missed
const lastId = Number(req.headers['last-event-id'] ?? 0)
for (const msg of history) {
if (msg.id > lastId) res.write(format(msg))
}
// 2) Keep streaming new events
clients.add(res)
req.on('close', () => clients.delete(res))
}).listen(3000)
// Demo: emit an order update every 2s
setInterval(() => publish('order-updated', { status: 'tick', at: Date.now() }), 2000)
In production you'd replace the in-memory array with Redis Streams, Kafka, a database table, or anything that gives you ordered, durable IDs.
Instead of implementing reconnection and event ID tracking manually, you can use the sse-resume plugin in tanstack-fetch.
Configure your client:
import { createFetch } from 'tanstack-fetch/sse'
const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
plugins: ['sse-resume'],
})
Consume your stream with a callback:
const stream = api.sse<OrderEvent>('/orders/stream', {
onMessage: (data) => {
console.log('Received:', data)
},
})
β¦or with async iteration:
for await (const event of api.sse<OrderEvent>('/orders/stream', { signal })) {
console.log(event.data)
}
With sse-resume enabled, tanstack-fetch:
You don't need to build the reconnection and ID-tracking layer yourself β and unlike native EventSource, you keep full control over headers (auth tokens, etc.).
Client-side resume is only half of the solution. Your server must:
Also think about event retention, expired IDs (what if the ID is older than your history?), duplicate delivery, and authorization on reconnect.
Important: Resumable delivery is not exactly-once delivery. Use idempotent event processing or deduplication (e.g. by event ID) on the client where it matters.
SSE resume is particularly useful for:
Whenever missing an event can leave the client in an inconsistent state, resumability deserves consideration.
SSE makes real-time communication relatively simple, but production systems need to think beyond "open a connection and read events". A reliable implementation should consider reconnection, event IDs, replay, duplicate handling, and cancellation.
You can implement these mechanisms yourself, rely on the browser's native EventSource, or use a fetch-based solution with automatic resume support.
If you're already using tanstack-fetch, enabling sse-resume is one line:
plugins: ['sse-resume']
π Docs & implementation: https://mohamadgarmabi.github.io/tanstack-fetch/recipes/sse-resume
tanstack-fetch is an independent open-source project, not an official TanStack package.
π¬ How do you handle dropped real-time connections in your apps β SSE, WebSockets, or polling? I'd love to hear your approach in the comments. If this was useful, a β€οΈ or a π¦ helps others find it!