TL;DR — SSE reconnects are easy; recovering the events you missed is not. The fix is
id+Last-Event-IDon 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.
1. Understanding the problem
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:
- Reconnecting to the server.
- Recovering events that were missed during the disconnection.
The second one requires cooperation between the client and the server.
2. SSE without automatic resume
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:
- Connection state management
- Reconnection logic
- Tracking the last processed event ID
- Sending the last event ID when reconnecting
- Handling duplicate events
- Retry delays and cancellation
You can build all of this yourself, but the complexity grows fast.
3. Understanding 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.
4. The server side: a minimal replay server (Node.js)
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.
5. Automatic SSE resume on the client with tanstack-fetch
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:
- ✅ Tracks the last received event ID
- ✅ Sends
Last-Event-IDwhen reconnecting - ✅ Automatically resumes after the stream closes
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.).
6. Server checklist
Client-side resume is only half of the solution. Your server must:
- Assign a stable, ordered ID to each event.
- Keep enough event history to support reconnecting clients.
- Read the incoming
Last-Event-IDheader. - Replay events after the supplied ID — not just start from the newest.
- Continue sending new events through the same stream.
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.
7. When should you use SSE resume?
SSE resume is particularly useful for:
- 📦 Live order and shipment tracking
- 💸 Financial dashboards and transaction updates
- 🔔 Notification systems
- ⏳ Long-running background jobs
- 🤖 AI-generated streaming responses
- 📊 Monitoring and observability dashboards
Whenever missing an event can leave the client in an inconsistent state, resumability deserves consideration.
Conclusion
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!
Top comments (0)