Testing Real-Time Frontends #

The test suite is green locally and red on CI roughly one run in eight. The failing test is always the same one: “reconnects after the server drops the connection.” It waits 1,100 ms for a reconnect scheduled at 1,000 ms, and on a loaded CI runner that margin evaporates. Someone bumps the wait to 3,000 ms, the suite gets ninety seconds slower, and six weeks later it starts flaking again.

Real-time UIs are hard to test for three specific reasons: the transport is asynchronous and stateful, the interesting behaviour is time-dependent (backoff, heartbeats, timeouts), and the thing under test usually reaches for a global WebSocket constructor it does not own. Fix those three and the tests become as boring as any other unit test. This guide covers the fake socket, the fake clock, and the end-to-end layer where you deliberately keep a real server.

Prerequisites #

You need a hook or store that is testable in the first place — one that accepts its socket factory rather than calling new WebSocket() inline. If yours does not, start with building a useWebSocket React hook with TypeScript, which threads the factory through as an option for exactly this reason. You also want the teardown contract from useWebSocket cleanup and teardown patterns settled first, because the most valuable tests you will write are the ones asserting that unmount actually closes the socket.

The examples use Vitest and Testing Library, but nothing here is Vitest-specific; the same shapes work in Jest with jest.useFakeTimers().

What to test at each layer of a real-time frontend Unit tests use a fake socket and fake timers for state transitions and backoff; integration tests use a real in-process WebSocket server; end-to-end tests drive a browser against a real deployment. Unit fake socket + fake clock state transitions backoff schedule cleanup on unmount malformed payloads milliseconds, zero flake Integration real ws server, in process handshake + subprotocol envelope round trip close codes auth rejection paths tens of tests, seconds End to end browser + deployment two tabs, one document proxy upgrade path offline then online sticky session routing a handful, minutes Push every assertion as far left as it will go; keep only what genuinely needs a browser on the right

Core implementation #

The fake socket is about forty lines and replaces every mocking library you were about to install. It implements the parts of the WebSocket interface your code touches, and — critically — exposes a handle so the test can drive the server side of the conversation.

import { vi } from 'vitest';

type Listener = (ev: any) => void;

/** A controllable stand-in for the browser WebSocket. */
export class FakeSocket {
static instances: FakeSocket[] = [];
static get last(): FakeSocket { return FakeSocket.instances[FakeSocket.instances.length - 1]; }

readyState = 0; // CONNECTING
sent: string[] = [];
closeCalls: Array<{ code?: number; reason?: string }> = [];
onopen: Listener | null = null;
onmessage: Listener | null = null;
onerror: Listener | null = null;
onclose: Listener | null = null;

constructor(public url: string, public protocols?: string | string[]) {
FakeSocket.instances.push(this);
}

send(data: string) {
if (this.readyState !== 1) throw new DOMException('InvalidStateError');
this.sent.push(data);
}

close(code = 1000, reason = '') {
this.closeCalls.push({ code, reason });
this.readyState = 3; // CLOSED
this.onclose?.({ code, reason, wasClean: code === 1000 });
}

// ── test-side controls: drive the server half of the conversation ──
acceptConnection() { this.readyState = 1; this.onopen?.({}); }
emitMessage(payload: unknown) { this.onmessage?.({ data: JSON.stringify(payload) }); }
emitRawMessage(data: string) { this.onmessage?.({ data }); }
/** A network drop, not a clean close — code 1006, exactly as browsers report it. */
dropConnection() {
this.readyState = 3;
this.onclose?.({ code: 1006, reason: '', wasClean: false });
}
}

export function installFakeSocket() {
FakeSocket.instances = [];
vi.stubGlobal('WebSocket', FakeSocket as unknown as typeof WebSocket);
return FakeSocket;
}

With that in place, the backoff test that used to flake becomes deterministic, because the clock is under your control rather than the CI runner’s:

import { renderHook, act, waitFor } from '@testing-library/react';
import { beforeEach, afterEach, expect, test, vi } from 'vitest';
import { FakeSocket, installFakeSocket } from './fake-socket';
import { useWebSocket } from '../src/useWebSocket';

beforeEach(() => { installFakeSocket(); vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });

test('reconnects on an abnormal close, and backs off between attempts', async () => {
const { result } = renderHook(() => useWebSocket('wss://example.test/ws'));

act(() => { FakeSocket.last.acceptConnection(); });
expect(result.current.status).toBe('open');

// A network drop, not a clean close: the hook must schedule a retry.
act(() => { FakeSocket.last.dropConnection(); });
expect(FakeSocket.instances).toHaveLength(1); // nothing yet — it is waiting

// Advance exactly to the first backoff ceiling. No sleeping, no wall clock.
act(() => { vi.advanceTimersByTime(1000); });
expect(FakeSocket.instances).toHaveLength(2); // attempt 2 opened

act(() => { FakeSocket.last.dropConnection(); });
act(() => { vi.advanceTimersByTime(1000); });
expect(FakeSocket.instances).toHaveLength(2); // still waiting: the window doubled
act(() => { vi.advanceTimersByTime(1000); });
expect(FakeSocket.instances).toHaveLength(3);
});

test('closes the socket exactly once on unmount', () => {
const { unmount } = renderHook(() => useWebSocket('wss://example.test/ws'));
act(() => { FakeSocket.last.acceptConnection(); });
const socket = FakeSocket.last;
unmount();
expect(socket.closeCalls).toHaveLength(1);
expect(socket.closeCalls[0].code).toBe(1000); // clean, so the server does not retry
});

The sequence the test drives is worth drawing once, because it is the mental model that makes every later test obvious: the test is the server, and every arrow that would normally come from the network is a method call you make explicitly.

How a test drives the fake socket through a reconnect The test calls acceptConnection to fire onopen, then dropConnection to fire onclose with code 1006, then advances the fake clock so the hook constructs a second socket. Test FakeSocket Hook acceptConnection() onopen — status open dropConnection() onclose 1006 — schedules retry advanceTimersByTime(1000) — no real waiting new WebSocket() — instance 2 assert

The schedule the fake clock steps through is the same one the production code computes, so it is worth having in front of you when you write the assertions — every step boundary below is a advanceTimersByTime call in a test.

Full-jitter backoff: the window each retry draws from Reconnect delay windows for 6 attempts computed from base 1000 ms doubling to a 16000 ms cap; each attempt sleeps a uniform random value inside its bar. Full-jitter backoff: the window each retry draws from base 1000 ms, cap 16 s — bar spans the random range, red line is the ceiling 0s 5s 10s 15s 20s try 1 try 2 try 3 try 4 try 5 try 6
The ceilings your assertions step through: advancing to 999 ms must not reconnect, advancing to 1,000 ms must.

If your hook uses jitter, seed it or inject the random source; a test that asserts on a jittered delay range rather than an exact value is a test that will eventually fail. Injecting a deterministic random and asserting the exact schedule is both stricter and stabler.

The tests that actually catch bugs #

A real-time test suite drifts towards testing the framework — that useState updates, that a mock was called — while the bugs that reach production live in a small, predictable set of transitions. These are the ones worth writing first, in rough order of how often they break.

Unmount during connect. The component mounts, the socket is still in CONNECTING, and the user navigates away. This is the single most common leak in real-time UIs, because close() on a connecting socket is legal but the socket still has to settle, and any code that assumes onclose means “we were open” mishandles it. The test is three lines: render, unmount before acceptConnection(), assert exactly one close call and no reconnect timer armed.

Abnormal close versus clean close. A 1000 close is the application saying “we are done” and must never trigger a reconnect; a 1006 is the network saying “you were cut off” and must. Systems that conflate them either reconnect after the user logs out — reopening a socket with a dead token, forever — or fail to reconnect after a real drop. Two tests, one per code, and they pay for themselves the first time someone refactors the close handler.

Message arriving after teardown. A frame that was already in flight when cleanup ran will still hit a handler that was not nulled. In production this surfaces as a state update on an unmounted component or, worse, a write into a store that the next mount then reads as fresh. Drive it explicitly: unmount, then call emitMessage() on the retained fake socket, and assert nothing changed.

Malformed payloads. Every real deployment eventually receives a frame that is not the JSON you expected — a proxy error page, a truncated frame, a message from an older server. The correct behaviour is to count it and continue; the common behaviour is an uncaught exception inside onmessage that kills the handler and silently freezes the UI. emitRawMessage('<html>502 Bad Gateway</html>') is the test.

Reconnect resets the backoff on success. After a successful reconnect, the attempt counter must return to zero. If it does not, a connection that drops once an hour ends up waiting the maximum backoff after a day of uptime, and the symptom — “it takes two minutes to come back, but only for users who have had the tab open a long time” — is nearly impossible to reproduce by hand. With fake timers it is a ten-line test.

Two mounts share one socket. If your app uses a shared connection, mounting a second consumer must not open a second socket, and unmounting one must not close the socket the other is still using. Reference-counting bugs here are subtle and expensive; the test is to render two hooks, assert FakeSocket.instances has length one, unmount one, and assert no close call yet.

Every one of these runs in single-digit milliseconds with the fake socket and fake timers, and none of them can flake, because nothing in them depends on wall-clock time or on a real network. That is the whole argument for the setup in this guide: the tests that matter most for real-time UIs are exactly the tests that are impossible to write reliably against a real connection.

Configuration reference #

Setting Where Default Recommended Notes
vi.useFakeTimers() test setup off on for unit tests Also fakes Date.now, which backoff and heartbeat logic reads.
shouldAdvanceTime fake timers false false Leaving it false is what makes tests deterministic; advance explicitly.
testTimeout Vitest config 5000 5000 With fake timers you never need to raise it — if you do, something is real.
pool Vitest config threads forks for WS servers Real socket servers in worker threads leak between files.
webServer.reuseExistingServer Playwright !CI !CI Prevents port races locally without weakening CI.
expect.poll interval Playwright 100 ms 50–100 ms Polling beats fixed waits for “message eventually arrives” assertions.
trace Playwright off on-first-retry Real-time failures are hard to reproduce; the trace usually contains the answer.

Edge cases & gotchas #

Fake timers and real promises deadlock easily. If your reconnect path awaits a real promise (a token fetch, say) and then schedules a timer, advancing the clock before the promise resolves does nothing, and the test hangs until timeout. Flush microtasks first — await Promise.resolve() or the library’s advanceTimersByTimeAsync — then advance.

jsdom has no WebSocket. Depending on your jsdom version, the global may be missing entirely, so a test that forgets to install the fake fails with a confusing WebSocket is not defined rather than a useful assertion. Install the stub in a global setup file so it is impossible to forget.

React 18 StrictMode doubles your instance count. In development-mode tests, effects run mount → cleanup → mount, so FakeSocket.instances has two entries before any reconnect. Assert on FakeSocket.last and on close-call counts rather than raw instance totals, or render without StrictMode in tests that count sockets.

End-to-end tests must not assert on timing. In Playwright, never waitForTimeout(1000) and then assert a reconnect happened. Wait for the observable consequence — a status pill flipping to “Live”, a row appearing — with an auto-retrying assertion. The one exception is deliberately testing a timeout, where the wait is the subject.

Verification #

The suite is trustworthy when it fails for the right reasons. Prove that by breaking things on purpose: delete the clearTimeout in your cleanup and confirm a test goes red; return a 1006 where the code expects 1000 and confirm the reconnect test notices; ship a malformed frame and confirm the parser test catches it rather than the UI throwing.

# Run the real-time suite with fake timers, no watch, deterministic order
npx vitest run --sequence.shuffle=false src/realtime

# Ten consecutive runs — a flaky async test almost always surfaces inside ten
for i in $(seq 1 10); do npx vitest run src/realtime || break; done

# Playwright with tracing so a rare failure is debuggable after the fact
npx playwright test --trace on-first-retry --repeat-each 3

Watch for tests that pass but leave handles open: Vitest reporting that it “could not close the process” after a WebSocket suite almost always means a socket or interval survived teardown — the same leak your users would experience as a zombie connection.

Guides in this area #

  • Mocking WebSockets in Vitest — the fake socket in full, fake-timer recipes for backoff and heartbeats, and how to keep StrictMode from confusing your assertions.
  • Playwright Tests for WebSocket Apps — driving two browser contexts against one document, intercepting the socket with page.routeWebSocket, and simulating offline transitions.

FAQ #

Should I mock the WebSocket or run a real server in tests? #

Both, at different layers. Mock it for everything about your state machine — status transitions, backoff, cleanup, malformed input — because those tests must be instant and deterministic. Run a real ws server for anything about the protocol contract: the handshake, subprotocol negotiation, close codes, auth rejection. A real server test that boots on an ephemeral port and shuts down in afterAll still runs in tens of milliseconds.

How do I test heartbeats without waiting 30 seconds? #

Fake timers make the interval free: advance 30,000 ms and assert a ping was sent, advance past the pong timeout and assert the socket was closed. The important detail is that fake timers also stub Date.now(), so if your heartbeat compares timestamps rather than counting ticks, it still behaves correctly under the fake clock. See implementing WebSocket ping/pong in Node.js for the server side of the same contract.

Can Playwright intercept WebSocket traffic? #

Yes. Recent versions ship page.routeWebSocket(), which lets you intercept the connection and either respond entirely from the test or proxy to the real server while observing frames. That makes it practical to test the UI’s reaction to a server-sent error frame without needing a server that can produce one on demand.

Why does my test pass alone and fail in the suite? #

Almost always shared global state: a stubbed WebSocket that a previous file left installed, a module-level singleton socket that survives between tests, or a timer that was never cleared. Reset globals in afterEach, avoid module-scope connections, and run the suite with a randomised order once in CI to surface ordering dependencies early.

Do I need to test reconnect logic end to end? #

Once, as a smoke test, and no more. The state machine belongs in unit tests where you can drive every branch in milliseconds; the end-to-end version exists only to prove that the real proxy, the real load balancer, and the real client agree — which is a different assertion, and the one that catches an nginx upgrade misconfiguration that no unit test can see.

How do I keep snapshot tests from breaking on every timestamp? #

Do not render raw timestamps into the markup you snapshot. Either format them through a function the test can stub, or assert on the parsed value rather than the rendered string. The same applies to anything derived from Date.now() in a status line — “connected 3s ago” is a moving target that will fail the moment CI is slow, whereas asserting the underlying connectedAt value is stable forever.

Should tests cover the server and client together? #

One integration test per protocol contract, yes: boot a real ws server on an ephemeral port, connect the real client code to it, and assert that a message sent by one arrives correctly decoded at the other. That single test catches envelope mismatches, subprotocol typos, and encoding bugs that neither side’s unit tests can see, because both sides are testing against their own assumption. Beyond that one contract test per message family, extra integration coverage buys very little for the runtime it costs.

Do I need to test the WebSocket library itself? #

No. Assume ws and the browser implementation are correct — they are exercised by far more traffic than your test suite ever will be. Your tests exist to pin down your state machine and your protocol handling: which close codes trigger a retry, what happens to a message that arrives after teardown, and whether the envelope you send is the envelope the server expects. Everything else is someone else’s regression suite.

Back to Frontend WebSocket State Hooks & UI Patterns