Stubbing Server-Sent Events in a Dev Server

Your notification feed uses EventSource, and the mock returns the whole event log as a single JSON blob, so nothing behaves the way it will in production. This page serves a genuine text/event-stream from the mock layer, with correct frame formatting, monotonic ids and working resume.

Context: the protocol is the point

Server-Sent Events is a deliberately small protocol, and every part of it does something the client relies on.

The frame format is line-oriented and terminated by a blank line. EventSource accumulates lines until it sees one, then dispatches. A missing blank line is not a formatting nit — the event never fires at all.

The id: field is what makes resume possible. The browser stores the last id it saw and sends it back as Last-Event-ID on reconnect. A stream with no ids can only ever replay from the beginning.

The event: field selects the listener. A frame with no event name dispatches to the generic message handler, so a mock that omits it will silently fail to trigger addEventListener('order.updated', …).

Anatomy of one SSE frame A single frame is shown line by line: an id line, an event line, a data line and a terminating blank line. Each is annotated with what the client does with it — the id becomes the resume cursor sent back as Last-Event-ID, the event name selects which listener fires, the data becomes the event payload, and the blank line is what dispatches the event at all. id: 41 event: order.updated data: {"id":"ord_1","status":"paid"} (blank line) one frame, four lines, ending in \n\n resume cursor returned as Last-Event-ID on reconnect listener selector omit it and only "message" fires the payload arrives as a string; parse it yourself the dispatch trigger without it nothing fires, ever Three of the four lines are optional in the specification; the blank line is the one that is not. A mock missing it produces a client that connects successfully and receives nothing — the hardest version of this bug to diagnose.

Solution

1. Return a stream that enqueues over time

// src/mocks/sse-handlers.ts
import { http, HttpResponse, delay } from 'msw';

interface SseEvent { id: number; event: string; data: unknown; }

const LOG: SseEvent[] = [
  { id: 40, event: 'order.updated', data: { id: 'ord_1', status: 'paid' } },
  { id: 41, event: 'order.updated', data: { id: 'ord_1', status: 'processing' } },
  { id: 42, event: 'order.updated', data: { id: 'ord_1', status: 'shipped' } },
  { id: 43, event: 'order.created', data: { id: 'ord_2', status: 'pending' } },
];

/** The trailing blank line is what dispatches the event. */
function frame(e: SseEvent): string {
  return `id: ${e.id}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`;
}

export const sseHandlers = [
  http.get('https://api.example.com/events', ({ request }) => {
    const since = Number(request.headers.get('last-event-id') ?? '0');
    const pending = LOG.filter((e) => e.id > since);

    const encoder = new TextEncoder();
    const stream = new ReadableStream({
      async start(controller) {
        // A comment frame flushes headers immediately, so the client's
        // `open` event fires before the first real event arrives.
        controller.enqueue(encoder.encode(': connected\n\n'));

        for (const e of pending) {
          await delay(80);
          controller.enqueue(encoder.encode(frame(e)));
        }
        controller.close();
      },
    });

    return new HttpResponse(stream, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
        // Stops a proxy in front of the dev server buffering the stream.
        'X-Accel-Buffering': 'no',
      },
    });
  }),
];

The : connected comment line is a small thing that removes a whole class of confusion. Without it the response headers may not flush until the first real frame, so EventSource.onopen fires late and any test that waits for it appears to hang.

2. Make reconnection resume rather than replay

The browser handles the reconnect; the mock has to handle the resume. The last-event-id read above is the whole implementation, and it is worth testing directly:

// src/features/live/feed.test.ts
import { expect, it } from 'vitest';

it('resumes after the last id rather than replaying', async () => {
  const res = await fetch('https://api.example.com/events', {
    headers: { 'Last-Event-ID': '41' },
  });
  const text = await res.text();

  expect(text).not.toContain('"status":"paid"');        // id 40 — already seen
  expect(text).toContain('"status":"shipped"');         // id 42 — new
  expect(text.match(/^id: /gm)).toEqual(['id: ', 'id: ']);  // exactly two frames
});

3. Control the retry interval

SSE lets the server suggest a reconnect delay with a retry: line. Setting a short one in development makes reconnection testable without a five-second wait:

controller.enqueue(encoder.encode('retry: 250\n\n'));

Send it once at the start of the stream. The browser remembers it for subsequent reconnects on the same EventSource, so a test can drop the stream and see the reconnect within a quarter of a second rather than the three-second default.

Automatic reconnect and server-side resume A first connection delivers events 40 and 41 and then ends. The browser waits the retry interval and reconnects on its own, sending Last-Event-ID 41. The mock filters its log on that value and delivers only 42 and 43, so the client renders each event exactly once. A note marks the filter as the only server-side work required. EventSource Mock handler GET /events — no Last-Event-ID yet retry: 250 · id 40 · id 41 · stream ends browser waits 250 ms and reconnects on its own GET /events — Last-Event-ID: 41 (sent automatically) id 42 · id 43 — nothing before 41 The client-side half is free. The server-side half is one filter — and omitting it is what produces duplicated rows after every blip. Set retry to a small value in development so the reconnect is observable inside a test's patience.

Verification

# Frames must arrive progressively, not in one burst
curl -N -H 'Accept: text/event-stream' http://localhost:5173/api/events

# Resume delivers only what follows the supplied id
curl -sN -H 'Last-Event-ID: 41' http://localhost:5173/api/events | grep -c '^id: '   # expect 2

-N disables curl’s own buffering, which is essential — without it the output arrives at once regardless of how the server behaved, and the check proves nothing.

Gotchas and edge cases

  • A proxy in front of the dev server can buffer the whole stream. Nginx in particular buffers proxied responses by default, so a stream that is perfectly incremental from the mock arrives as one chunk in the browser. The X-Accel-Buffering: no header above disables it for nginx; other proxies need their own directive, covered under nginx reverse proxy for local mock APIs.

  • EventSource cannot send custom headers. There is no way to attach an Authorization header to a native EventSource, which is why real deployments authenticate SSE by cookie or by a token in the query string. Mock whichever your API uses — a handler expecting a bearer header will never match a request the browser cannot make.

  • Multi-line data needs one data: prefix per line. A payload containing a newline must be emitted as consecutive data: lines, which the client rejoins with newlines. Emitting the raw newline instead terminates the field early and the event arrives truncated. JSON.stringify avoids this entirely by escaping newlines, which is one more reason to send JSON rather than raw text.


The headers that matter Four response headers. The content type identifies the stream. No-cache prevents a stale replay. Keep-alive holds the connection. And a buffering-disable header stops an intervening proxy collecting the whole stream before forwarding it. Content-Type: text/event-stream identifies the stream to the client without it EventSource errors immediately Cache-Control: no-cache prevents a cached replay a cached stream replays old events Connection: keep-alive holds the connection open some intermediaries close it otherwise X-Accel-Buffering: no stops nginx buffering the stream frames otherwise arrive in one burst The last one is invisible locally and decisive the moment a proxy sits in front of the dev server.

Where a mocked stream diverges from a real one

A mocked event stream can be made faithful in most respects, and it is worth knowing the handful of places where it cannot, so the gap is a known one rather than a surprise.

Backpressure does not exist. A real server producing faster than a client consumes eventually feels it — buffers fill, the connection stalls. A mock enqueueing into a ReadableStream in the same process has no such feedback, so a client that cannot keep up looks fine locally and falls behind in production.

Intermediaries are absent. In production a stream passes through load balancers, proxies and possibly a CDN, any of which may buffer, time out on idle, or close a long-lived connection. A local mock has none of those, which is why a stream that works perfectly in development sometimes dies after sixty seconds in a real deployment — an idle timeout nobody knew about.

Reconnection timing is idealised. The browser reconnects after the retry interval; in reality that reconnect may fail, back off, and fail again on a poor network. A mock that always accepts the reconnect never exercises the path where several reconnects fail in a row.

Message loss is not modelled. SSE resumes from the last event id, which assumes the server can still produce the events after that id. A real server with a bounded buffer may not be able to, and the client’s behaviour when the resume point is no longer available is a genuine branch that a mock with a complete in-memory log never reaches.

Each of these can be simulated deliberately — close the stream on a timer, refuse the first reconnect, respond to an old Last-Event-ID with a full-resync signal — and each is worth one spec. The point is to know they are absent by default rather than to assume the mock covers them.

← Back to WebSocket & SSE Mocking