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
wsAPI 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.
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 });
});
Verification steps
-
npx vitest run src/features/livepasses with the streaming handlers registered - A
POST /ordersin 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/eventsprints 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 inafterEach, 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.
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.
Related
- Mocking WebSocket Messages Locally — scripted frame sequences and heartbeat handling in detail
- Stubbing Server-Sent Events in a Dev Server — the streaming response, resume cursor and reconnect path
- Advanced MSW Handler Patterns — the resolver techniques these stream handlers build on
- Modeling CRUD State in a Mock Server — the shared store both surfaces read from
- Stubbing GraphQL Subscriptions Locally — the same problem expressed over a GraphQL transport
← Back to Tool-Specific Implementation & Setup