WebSocket & SSE Mocking

This guide covers mocking the streaming transports your application uses — WebSocket frames and Server-Sent Events — so live-updating features can be developed and tested without a running broker. It does not cover request/response mocking, which the MSW setup guide handles, nor real message-broker infrastructure such as a Kafka cluster.

Prerequisites

  • MSW 2.x installed (the ws API landed in 2.x and does not exist in 1.x)
  • A test runner able to drive timers deterministically — Vitest or Jest with fake timers
  • The stream’s message schema written down: event names, payload shapes, and the ordering guarantees you rely on
  • Your client’s reconnect policy identified — delay, backoff, and whether it sends a resume cursor
  • Familiarity with request interception patterns, because a stream mock sits at the same layer

Two transports, two different failure surfaces

WebSocket and Server-Sent Events look interchangeable from a feature brief — “the page updates live” — and behave nothing alike underneath. Choosing the wrong mock produces tests that pass while the real feature breaks.

A WebSocket is bidirectional and stateful. The client sends frames as well as receiving them, which means the mock has to respond to what the client says: subscribe messages, heartbeats, acknowledgements. Ordering is guaranteed within the connection, but nothing is replayed after a drop unless you build that yourself.

Server-Sent Events are one-way and built on plain HTTP. The browser’s EventSource reconnects automatically, and it sends the last event ID back so the server can resume — a protocol feature you get for free and must therefore mock, because a client that silently re-renders the whole stream after a blip is a bug your users will see as duplicated rows.

WebSocket versus Server-Sent Events, from the mock's point of view Two columns. The WebSocket column shows an upgrade handshake, then bidirectional frames including a client subscribe and a heartbeat, then a close with no automatic replay. The Server-Sent Events column shows a plain GET, a one-way stream of id-tagged events, an automatic browser reconnect carrying Last-Event-ID, and a resumed stream. Beneath each column is the list of behaviours the mock must therefore implement. WebSocket — bidirectional Server-Sent Events — one-way client mock HTTP upgrade handshake subscribe { channel: "orders" } order.updated frames ping — the mock must pong close — nothing is replayed client mock GET /events — plain HTTP id: 41 · event: order.updated connection drops auto-reconnect, Last-Event-ID: 41 resumes at id 42 — no duplicates The mock must therefore: read client frames and reply to them answer heartbeats, or the client drops the link implement replay itself if the feature needs it The mock must therefore: stream the body incrementally, not all at once tag every event with a monotonic id honour Last-Event-ID on reconnect

The heartbeat row is the one that catches people out. A client configured to close the socket when it sees no pong within fifteen seconds will silently drop your mocked connection mid-test, and the resulting failure looks like a flaky assertion rather than a missing frame.

Phase 1 — intercepting a WebSocket

MSW’s ws API gives you the server side of the connection. Client frames arrive as events; you push frames back with client.send:

// src/mocks/ws-handlers.ts
import { ws } from 'msw';

const orders = ws.link('wss://api.example.com/stream');

export const wsHandlers = [
  orders.addEventListener('connection', ({ client }) => {
    client.addEventListener('message', (event) => {
      const msg = JSON.parse(String(event.data));

      if (msg.type === 'ping') {
        client.send(JSON.stringify({ type: 'pong', at: Date.now() }));
        return;
      }

      if (msg.type === 'subscribe' && msg.channel === 'orders') {
        client.send(JSON.stringify({ type: 'subscribed', channel: 'orders' }));
        // Then push the scripted sequence.
        for (const frame of SEQUENCE) {
          client.send(JSON.stringify(frame));
        }
      }
    });
  }),
];

const SEQUENCE = [
  { type: 'order.updated', id: 'ord_1', status: 'processing', seq: 1 },
  { type: 'order.updated', id: 'ord_1', status: 'shipped',    seq: 2 },
  { type: 'order.updated', id: 'ord_2', status: 'paid',       seq: 3 },
];

Register the WebSocket handlers alongside the HTTP ones — they share the same worker and the same lifecycle:

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
import { wsHandlers } from './ws-handlers';

export const worker = setupWorker(...handlers, ...wsHandlers);

The seq field is not decoration. Without a monotonic sequence number the test cannot distinguish “rendered messages 1, 2, 3” from “rendered 1, 3, 2”, and out-of-order rendering is one of the two bugs this whole area exists to catch.

Phase 2 — streaming Server-Sent Events

EventSource parses the body as it arrives, so the mock must produce it as a stream. A ReadableStream with a controlled enqueue rate reproduces that faithfully:

// 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' } },
];

function frame(e: SseEvent): string {
  // The trailing blank line is what terminates an SSE frame. Omit it and the
  // client buffers forever waiting for the rest of the message.
  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 stream = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();
        for (const e of pending) {
          await delay(80);                       // events arrive over time
          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',
      },
    });
  }),
];

The last-event-id read is the whole point. EventSource sends that header automatically on every reconnect, so filtering on it is what makes resume work — and a test that drops the connection halfway then asserts no duplicate rows is testing a real protocol behaviour rather than a contrivance.

Phase 3 — integrating with the rest of the mock stack

Streaming mocks share state with the request/response mocks more often than not: a POST /orders should produce an order.created event on the stream. Keep one store and have both surfaces read it, exactly as modeling CRUD state in a mock server describes:

// src/mocks/store.ts
type Listener = (e: { id: number; event: string; data: unknown }) => void;

const listeners = new Set<Listener>();
let nextId = 1;

export const orders = new Map<string, { id: string; status: string }>();

export function emit(event: string, data: unknown): void {
  const e = { id: nextId++, event, data };
  for (const l of listeners) l(e);
}

export function subscribe(l: Listener): () => void {
  listeners.add(l);
  return () => listeners.delete(l);
}

export function resetStore(): void {
  orders.clear();
  listeners.clear();
  nextId = 1;
}

Now the HTTP handler that creates an order also drives the stream, and a test can assert the end-to-end behaviour: post an order, see it appear in the live list without a refetch.

http.post('https://api.example.com/orders', async ({ request }) => {
  const body = (await request.json()) as { id: string };
  orders.set(body.id, { id: body.id, status: 'pending' });
  emit('order.created', { id: body.id, status: 'pending' });
  return HttpResponse.json({ id: body.id, status: 'pending' }, { status: 201 });
});
One store, two surfaces A central store holds the order records and an event sequence counter. HTTP handlers write to it when a POST arrives and read from it for GET requests. The WebSocket and Server-Sent Events handlers subscribe to it and push every emitted event to connected clients. A reset function clears records, listeners and the sequence counter between tests. Shared store orders map + listener set monotonic event id HTTP handlers POST /orders writes GET /orders reads resetStore() clears records, listeners and the id counter WebSocket handler subscribes, pushes frames answers ping with pong SSE handler enqueues id-tagged frames filters on Last-Event-ID A POST through the HTTP surface must be observable on the streaming surface — that is the integration worth asserting.

Verification steps

  • npx vitest run src/features/live passes with the streaming handlers registered
  • A POST /orders in a test produces exactly one frame on the stream, not zero and not two
  • Dropping the connection mid-stream and reconnecting renders no duplicated row
  • curl -N -H 'Accept: text/event-stream' http://localhost:5173/api/events prints frames progressively rather than all at once at the end
  • The client’s heartbeat receives a pong within its timeout — check by leaving a connection open longer than the configured interval
  • resetStore() runs in afterEach, and running the suite twice in a row produces identical results

The curl -N check is the quickest way to catch the single most common SSE mistake. Without -N, curl buffers and everything appears at once, so run it with the flag: if frames still arrive in one burst, the mock is building the whole body before returning rather than streaming it.

Reading a streaming failure Five symptoms with the protocol detail responsible. A silent stream usually means a missing blank line between SSE frames. A socket closing seconds in means unanswered heartbeats. Duplicated rows after a blip mean the resume cursor is ignored. Frames arriving all at once mean the response was buffered rather than streamed. Symptom Protocol detail Fix no events ever fire frames end on a blank line emit two newlines, not one the socket closes after seconds the client's ping went unanswered answer ping with pong rows duplicate after a reconnect Last-Event-ID is ignored filter the log on the header all frames arrive at once the body was built, not streamed enqueue over time, disable proxy buffering events fire but nothing renders no event name, so only message fires emit an event line per frame Every row is a protocol detail rather than a logic bug, which is why they resist debugging by reading application code.

Troubleshooting

ws is not exported from 'msw'. The ws API is MSW 2.x only. npm ls msw will show a 1.x install; upgrade, and note that the handler signature for HTTP handlers changes at the same time.

The client hangs and no events arrive. Almost always a missing blank line between SSE frames. The protocol terminates a message on \n\n; with a single newline the client waits indefinitely for the rest of a message that has already been sent in full.

The socket closes a few seconds into every test. The client’s heartbeat is going unanswered. Add the ping/pong branch shown above, and check the interval — a mock that pongs on a longer cycle than the client’s timeout fails in exactly the same way as one that never pongs.

Events arrive out of order in the UI. The client is rendering on arrival rather than on sequence. Have the mock deliberately emit seq: 2 before seq: 1 in one spec; if the UI shows them in arrival order, the client needs to sort or discard stale sequences. This is a real bug that a well-behaved mock will otherwise hide forever.

Reconnect duplicates every row. The client is not sending, or the mock is not honouring, the resume cursor. Log request.headers.get('last-event-id') in the handler: if it is null, the client is creating a fresh EventSource on reconnect instead of letting the browser reconnect the existing one.

When to advance

You are ready to move on once a live-updating feature can be built end to end with no broker running, once dropping and resuming the stream is covered by a test rather than by hope, and once the streaming and request surfaces share one store so they cannot disagree. From there, the natural next step is wiring these mocks into browser-level tests, which is what browser test runner integration covers.


Why streaming is the least-mocked half of most APIs

Almost every team that mocks its request/response endpoints thoroughly leaves the streaming half untouched, and the reasons are worth naming because they are all solvable.

The first is that streaming feels like infrastructure rather than API surface. A REST endpoint has a URL and a body and looks like something to mock; a WebSocket looks like a connection to a broker, and mocking a broker sounds like a large undertaking. It is not — the client’s view of a socket is a sequence of frames, and producing a sequence of frames is a handful of lines.

The second is that the failure modes are unfamiliar. A team that knows exactly what to assert about a 500 often has no vocabulary for what to assert about a dropped connection, so the tests are not written rather than being written badly. The vocabulary is small: did it reconnect, did it resume from the right place, did anything render twice, and did anything render out of order.

The third is that the local experience without a mock is tolerable enough to defer. If staging has a broker running, a developer can point at it and the feature appears to work. What that hides is every case where the broker is not behaving: a reconnect, a gap in the sequence, a message arriving after the component unmounted. Those cases never occur against a healthy staging broker and occur constantly in production, where connections are dropped by proxies, mobile networks and laptop lids.

The fourth is that the bugs are invisible until they are embarrassing. A duplicated row after a reconnect looks like a data problem to whoever reports it. An out-of-order status update looks like a backend bug. Neither is diagnosed as a streaming-client defect until somebody reproduces it, and reproducing it requires exactly the mock this section describes.

There is a fifth, more practical reason: teams often do not know which transport they are on. A library abstracting over WebSocket, long-polling and Server-Sent Events picks one at runtime, and the fallback path is frequently the one that has never been exercised locally. Mocking forces the question to be answered, which is worth something on its own.

The investment is genuinely small — a handler that answers a subscribe, a heartbeat and a close is under fifty lines — and it converts an entire category of production-only failure into something that fails in a test run instead.

Where to start with an existing live feature

If a streaming feature already exists and was built without a mock, the cheapest first step is not to mock everything. It is to mock the disconnect.

Almost every defect in this area is a reconnection defect: rows duplicated, a gap in the sequence, a stale view that never recovers. A mock that does nothing but serve the normal stream and then drop it on command exercises all of them, and it can usually be written in an afternoon.

Full scripted sequences, heartbeat handling and resume semantics are worth adding afterwards, once the disconnect test has found whatever it is going to find — which in most existing implementations is a duplicate render.

FAQ

Can MSW intercept WebSocket connections?

Yes. MSW 2.x ships a ws handler that intercepts WebSocket construction in both the browser and Node, giving you the server side of the connection: you read client frames and push your own. It replaces the older pattern of monkey-patching the global WebSocket constructor in test setup, which was fragile because it only ever covered the code paths that happened to run after the patch was installed.

Should I mock Server-Sent Events as a stream or as a plain response?

As a stream. EventSource parses the body incrementally, so returning the whole event log as one string only works if the client happens to read it all at once — which it will in a fast test and will not in a real browser. Returning a ReadableStream that enqueues events over time reproduces the incremental parsing, and it is the only way to test what the UI does between the first and last event.

How do I test reconnection without waiting for real backoff?

Close the socket from the mock, then advance fake timers past the client’s reconnect delay. Because the mock records each connection, the test can assert that the reconnect happened at all, that the resume cursor was sent with it, and that nothing already rendered was rendered again. All three are separate bugs and all three are invisible without a mock that can drop a connection on command.


← Back to Tool-Specific Implementation & Setup