DEV Community

Alex Chen
Alex Chen

Posted on

Build a Reconnecting SSE Task Stream With Node.js

A long-running AI task needs to report more than “loading.” It may be queued, editing, testing, waiting for input, retrying, or complete.

For a learning project, Server-Sent Events (SSE) are a small way to study that state flow. The server sends UTF-8 events over one HTTP response, the client remembers an event ID, and a reconnect can continue after the last accepted event.

Important scope note: MonkeyCode uses WebSocket, not SSE, for the task streams I reviewed. Its mobile task-stream client includes reconnection, queued replies, and deduplication behavior. I used those reliability concerns as inspiration for this smaller SSE exercise, not as a description of MonkeyCode's transport.

Prerequisites

You need Node.js 20 or newer. There are no packages to install.

The complete companion project has two files:

sse-task-stream.mjs  # server, parser, reconnecting consumer
test-sse.mjs         # deliberate disconnect and assertions
Enter fullscreen mode Exit fullscreen mode

Step 1: send identifiable events

An SSE record ends with a blank line. id is the resume cursor, event names the event type, and data carries the payload.

res.write(
  `id: ${event.id}\n` +
  `event: task\n` +
  `data: ${JSON.stringify(event)}\n\n`
);
Enter fullscreen mode Exit fullscreen mode

Our states are deliberately deterministic:

const events = ["queued", "running", "testing", "complete"];
Enter fullscreen mode Exit fullscreen mode

On the first connection, the test server sends only events 1 and 2, then closes. This simulates an interrupted response without waiting for a real network failure.

Step 2: resume from Last-Event-ID

The client stores the greatest accepted ID. Its next request includes:

Last-Event-ID: 2
Enter fullscreen mode Exit fullscreen mode

The server filters its replayable event log:

const last = Number(req.headers["last-event-id"] ?? 0);
const pending = events
  .map((state, index) => ({ id: index + 1, state }))
  .filter((event) => event.id > last);
Enter fullscreen mode Exit fullscreen mode

This example keeps the log in memory. A production server needs durable retention, authorization, bounded history, and a snapshot rule for cursors that have expired.

Step 3: deduplicate anyway

Even with cursors, clients should handle replay. The consumer stores accepted events in a Map keyed by ID:

const seen = new Map();
seen.set(event.id, event);
lastEventId = Math.max(lastEventId, event.id);
Enter fullscreen mode Exit fullscreen mode

Replacing the same key makes duplicate delivery harmless for this state projection. Real effects—charging a card or merging a PR—need server-side idempotency too.

Run the disconnect test

node test-sse.mjs
Enter fullscreen mode Exit fullscreen mode

Expected output:

PASS reconnected, resumed after event 2, and converged without duplicates
Enter fullscreen mode Exit fullscreen mode

The test asserts the exact final sequence:

1 queued
2 running
3 testing
4 complete
Enter fullscreen mode Exit fullscreen mode

It also verifies that four event IDs produce four records after reconnection.

Common mistakes

Using SSE for two-way interactive traffic. Browser EventSource is server-to-client. Commands need separate HTTP requests, or you may prefer WebSocket when both directions are frequent.

Treating reconnect as recovery. A socket reopening is not enough. The client needs a cursor or a fresh authoritative snapshot.

Using array position as permanent identity. This demo can because the event list is fixed. Production IDs must remain stable across processes and restarts.

Never expiring history. Keep a retention window and define what happens when Last-Event-ID is too old—usually return a snapshot cursor, not a partial story.

Assuming exactly-once delivery. Design for at-least-once events and idempotent projection.

What this project teaches

After completing it, you should be able to explain the difference between connection recovery and state recovery; why an event needs an identity; how Last-Event-ID supports replay; and why deduplication belongs in the consumer even when the server tries to resume precisely.

Those concepts transfer to WebSocket clients, message queues, change streams, and long-running agent interfaces. The wire format changes; the convergence problem remains.

Disclosure: I contribute to the MonkeyCode project. The MonkeyCode transport statement is based on the linked source at commit c58bcd4; this SSE project is a standalone learning implementation tested locally.

Top comments (0)