Build a Server-Sent Events Broadcast Service
A complete SSE service: event streams, per-channel fan-out, resumable Last-Event-ID, heartbeats, backpressure, and reconnect tests.
Project goal
Stream server events to browser clients over SSE, resume from the last seen event id after a reconnect, and drop slow clients without stalling the broadcaster.
Final features
- Setup stream
- Assign ids
- Write events
- Resume with Last-Event-ID
- Heartbeat and backpressure
Architecture
Clients subscribe with a channel. The broadcaster assigns a monotonic event id per channel, writes to each response, and keeps a short ring buffer for replay. A heartbeat comment keeps proxies from closing idle streams.
Prerequisites
- Node.js 20 LTS or later
- A proxy that does not buffer responses
- A browser or SSE client for testing
Dependencies
- express ^4.21.0 — SSE endpoint
Directory structure
sse-service/
├── src/stream.ts
├── src/broadcast.ts
├── src/replay.ts
├── src/heartbeat.ts
├── tests/sse.test.ts
└── package.json
Phases
1. Setup stream
Set the SSE headers and disable proxy buffering.
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
Expected behavior: Wrong headers cause the browser to buffer or the proxy to hold the stream.
2. Assign ids
Give every event a monotonic id within its channel.
const id = ++channel.sequence;
Expected behavior: Clients need ids to resume after a reconnect.
3. Write events
Format id, event, and data fields correctly.
res.write('id: ' + id + '\nevent: ' + name + '\ndata: ' + JSON.stringify(payload) + '\n\n');
Expected behavior: The wire format is line-based and easy to get subtly wrong.
4. Resume with Last-Event-ID
Replay buffered events after the client header value.
const since = Number(req.headers['last-event-id'] ?? 0);
for (const e of channel.buffer.filter((e) => e.id > since)) res.write(format(e));
Expected behavior: A reconnect should not silently miss events.
5. Heartbeat and backpressure
Send a comment heartbeat and drop clients whose buffer is full.
if (res.writableLength > MAX) return res.end();
Expected behavior: Idle proxies close streams and slow clients must not block others.
Testing
Connect a client, broadcast an event, and assert receipt; disconnect and reconnect with Last-Event-ID and assert the missed events are replayed.
Troubleshooting
Events arrive in bursts after a while
The proxy buffered the response; disable buffering and send a periodic heartbeat.
Reconnect misses events
The client did not send Last-Event-ID, so the server could not replay.
Deployment
Run behind a proxy configured with buffering off and a long idle timeout. For multiple instances, fan out events through Redis pub/sub; the in-memory buffer is single-instance.
Known limitations
- Single-instance buffer; multi-instance needs pub/sub.
- Buffer is bounded, so long disconnects may miss events.
- No client-to-server messaging.
Project overview
Server-sent events are the simplest way to push updates to a browser, and the two things that break are reconnection and a slow client. This build assigns every event an id, replays from the last seen id, and drops clients that cannot keep up.
You will subscribe clients to a channel, assign a monotonic id per channel, write each event to every subscriber, keep a short ring buffer for replay, and send a heartbeat so idle proxies do not close the stream.
Replay from the last event id is what makes a reconnect invisible to the user. Without it, a browser that loses a connection briefly will silently miss everything that happened during the gap.
Backpressure is the other half: one client on a slow connection must not hold the broadcaster, so each subscription has a bounded buffer and a client that overflows it is dropped rather than waited for.
Each phase adds one property: the stream, the ids, the writes, the resume path, and the heartbeat and backpressure, so the stream is usable and robust at every stage.
It is for teams that need live updates without the complexity of a bidirectional protocol, and who want the browser to reconnect and catch up on its own. The service keeps the server side simple and puts the recovery logic in a replayable id.
It is also a good illustration of why long-lived connections need explicit liveness handling: without a heartbeat and a bounded buffer, the stream appears to work while quietly dropping the clients that matter least to the person testing it.
What you will build
The service provides five capabilities, each with a test that uses a real connection:
- Setup stream
- Assign ids
- Write events
- Resume with Last-Event-ID
- Heartbeat and backpressure
A subscribe endpoint establishes the stream; per-channel ids make events addressable; writes fan out to subscribers; resume replays missed events from the last id; and a heartbeat plus bounded buffers keep the stream alive without letting one client stall it.
The design keeps the ring buffer small and per channel, so replay is bounded and an instance that is not the original one is documented as unable to replay.
Architecture and data model
Clients subscribe with a channel. The broadcaster assigns a monotonic event id per channel, writes to each response, and keeps a short ring buffer for replay. A heartbeat comment keeps proxies from closing idle streams.
A client subscribes with a channel name and receives an event stream. The broadcaster keeps a monotonic counter per channel, so every event has an id that a client can send back on reconnect.
Each event is written to every subscriber’s response, and a small ring buffer per channel retains recent events so a reconnect can replay the ones the client missed.
The response disables buffering and sends a periodic heartbeat comment, which keeps intermediaries from closing a stream that appears idle.
Each subscriber has a bounded queue: if it fills, the connection is closed so a slow client cannot block the broadcaster or grow memory without bound.
Because the id is per channel and monotonic, a client can resume from any point it saw, and the server does not need to track which client received what; that per-client statelessness is what keeps the server cheap to operate.
The ring buffer is deliberately small and per instance, which keeps memory bounded and makes the single-instance limitation explicit rather than hiding it behind an apparently global replay.
Prerequisites and dependencies
You need an HTTP server that can hold long-lived responses and, for multiple instances, a shared pub/sub channel.
- Node.js 20 LTS or later
- A proxy that does not buffer responses
- A browser or SSE client for testing
The dependencies cover the HTTP surface and the shared channel, with the ring buffer in process:
express(^4.21.0) — SSE endpoint
pnpm init
pnpm add express
pnpm add -D typescript tsx vitest
Set the heartbeat interval and the idle timeout together, with the heartbeat shorter than the timeout, and record the ring buffer size because it bounds how far back a reconnect can replay.
Directory structure
Separate the subscribe endpoint, id assignment, the write path, the replay buffer, and the heartbeat so each is testable with a pair of connections:
sse-service/
├── src/stream.ts
├── src/broadcast.ts
├── src/replay.ts
├── src/heartbeat.ts
├── tests/sse.test.ts
└── package.json
Phase 1: Setup stream
Set the SSE headers and disable proxy buffering.
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
Wrong headers cause the browser to buffer or the proxy to hold the stream.
Establish the stream with the right headers so the client and any intermediary treat it as an event stream rather than as a document that is still loading.
Phase 2: Assign ids
Give every event a monotonic id within its channel.
const id = ++channel.sequence;
Clients need ids to resume after a reconnect.
Assign a monotonic id per channel and include it with every event, because the id is what a reconnecting client sends to ask for what it missed.
Phase 3: Write events
Format id, event, and data fields correctly.
res.write('id: ' + id + '\nevent: ' + name + '\ndata: ' + JSON.stringify(payload) + '\n\n');
The wire format is line-based and easy to get subtly wrong.
Write each event to every subscriber on the channel, and record it in the ring buffer, so the same code path serves live delivery and replay.
Phase 4: Resume with Last-Event-ID
Replay buffered events after the client header value.
const since = Number(req.headers['last-event-id'] ?? 0);
for (const e of channel.buffer.filter((e) => e.id > since)) res.write(format(e));
A reconnect should not silently miss events.
Handle a reconnect that carries the last event id by replaying from the buffer, and if the id is older than the buffer, tell the client to resynchronize rather than sending a partial history.
Phase 5: Heartbeat and backpressure
Send a comment heartbeat and drop clients whose buffer is full.
if (res.writableLength > MAX) return res.end();
Idle proxies close streams and slow clients must not block others.
Send a heartbeat and enforce a bounded per-subscriber queue, so idle streams survive and slow clients are dropped instead of stalling the broadcaster.
Testing
Connect a client, broadcast an event, and assert receipt; disconnect and reconnect with Last-Event-ID and assert the missed events are replayed.
Connect a client, broadcast an event, and assert receipt, using real connections so the framing and the headers are exercised.
Disconnect and reconnect with the last event id and assert the missed events are replayed, which is the property that makes reconnection invisible.
Fill a subscriber’s queue and assert the connection is closed rather than the broadcaster blocked, which pins backpressure as behavior.
Assert that an id older than the buffer produces a resynchronize signal rather than a partial replay, since a silent partial history is worse than a clear instruction to reload.
Drive the test with two real connections and an in-process server, because the properties that matter, framing and reconnect, are invisible to a unit test of the broadcaster object.
Assert that the heartbeat arrives within its interval on an idle channel, because a missing heartbeat causes intermediaries to close the stream and clients to reconnect in a tight loop.
pnpm vitest run
Deployment and operations
Run behind a proxy configured with buffering off and a long idle timeout. For multiple instances, fan out events through Redis pub/sub; the in-memory buffer is single-instance.
Run behind a proxy configured with buffering off and a long idle timeout, because a proxy that buffers will hold events until the connection closes, which is the opposite of streaming.
For multiple instances, fan out through a shared pub/sub channel, and document that the in-memory ring buffer is single-instance and cannot replay across instances.
Monitor connection count, event rate, and dropped-client count, because the drop rate is the clearest signal that clients cannot keep up.
Set the heartbeat shorter than the idle timeout so a quiet channel is not closed by an intermediary.
Set the proxy idle timeout above the heartbeat interval by a comfortable margin, because a timeout equal to or shorter than the heartbeat closes a healthy stream and looks like a client problem.
Record the number of instances and the channel names they use, because a fan-out that reaches only some instances is a naming mistake that produces occasional missing events.
Security considerations
Authorize the channel before subscribing, avoid sending private data on shared channels, and cap connection counts per user to prevent resource exhaustion.
Authorize each subscription at subscribe time and re-check access when events are written, so a long-lived connection cannot outlive a revoked permission.
Handle the last event id from the client as an untrusted value and clamp it to the buffer range, since a crafted id must not read unrelated history.
Bound the number of connections per client and the ring buffer size, so a client cannot exhaust memory by opening many subscriptions.
Do not put sensitive data in event ids or channel names, because both appear in logs and in the client’s own connection state.
Re-check authorization when writing an event as well as when subscribing, because a connection can stay open long enough for a permission to be revoked, and clamp a client-supplied last event id to the available buffer range.
Reject a subscription to a channel the client is not entitled to at subscribe time, because a long-lived stream to the wrong channel leaks little by little rather than all at once.
Observability
Track active connections, events delivered per second, dropped clients, and reconnect rate. Alert on a reconnect spike, which suggests a proxy timeout.
Track active connections and the event rate per channel, because a channel with many connections and a low event rate is a candidate for polling instead.
Chart the reconnect rate, since a high rate usually means an idle timeout shorter than the heartbeat rather than unstable clients.
Measure the dropped-client rate, because it is the direct measure of backpressure and usually points at a slow consumer or an undersized buffer.
Monitor ring buffer resynchronizations, since a rising count means clients are reconnecting after more events than the buffer holds.
Track buffer utilization, because a buffer that is consistently full means reconnects are losing events and either the buffer or the resynchronization path needs attention.
Measure the time between an event being produced and delivered, because a growing delay means the broadcaster is queueing rather than writing promptly.
Troubleshooting
Events arrive in bursts after a while. The proxy buffered the response; disable buffering and send a periodic heartbeat.
Reconnect misses events. The client did not send Last-Event-ID, so the server could not replay.
If events arrive in bursts rather than promptly, check proxy buffering before the application, because a buffering intermediary batches a stream that the server wrote continuously.
If clients miss events after a reconnect, check whether they are connected to a different instance than the one that broadcast, since the in-memory buffer cannot replay across instances.
If a channel has subscribers but no events arrive, check the shared channel configuration, because an instance publishing to a different channel than it subscribes to will silently deliver nothing.
If the server writes events but the browser sees nothing, check the response content type, because a stream served with the wrong type is treated as a document and never parsed as events.
Limitations and next steps
- Single-instance buffer; multi-instance needs pub/sub.
- Buffer is bounded, so long disconnects may miss events.
- No client-to-server messaging.
Replay is bounded by an in-memory ring buffer, so a reconnect after a long gap requires a full resynchronization rather than a complete history.
Delivery is not durable; an event broadcast while a client is disconnected may be lost if it falls out of the buffer.
There is no client-side ordering guarantee across channels, only within a single channel’s stream.
The service does not authenticate the initial page load, so authorization is per subscription and depends on the session already being established.
There is no durable event log, so replay is limited to the ring buffer and anything older is unrecoverable; a durable stream would be needed for guaranteed delivery.
There is no event compression or batching, so a high-frequency channel sends one frame per event, which the client must be prepared to keep up with.