Simulating Network Errors and Timeouts With MSW
Almost every client-side bug report that starts with “sometimes” is a network failure the code did not expect: a request that hangs rather than fails, a 502 from a load balancer with an HTML body instead of JSON, a connection reset halfway through a page load. These paths are rarely tested because the real network almost never produces them on demand, and when it does, it is in production. MSW makes each of them a one-line handler. This guide covers producing connection errors, error status codes, slow and never-resolving responses, and malformed bodies with the MSW v2 API, then testing the behaviour that matters: what the user sees, whether retries happen, and whether a timeout actually fires. It sits under external service simulation.
Root Cause Analysis
Network code is usually written against the happy path and a single imagined failure: the server returns an error status with a JSON error body. Real failures are more varied, and each variety breaks a different assumption. A connection error rejects the fetch promise instead of resolving with an error status, so code that only checks response.ok never runs its error branch. A gateway error from infrastructure returns HTML, so response.json() throws a parsing error that surfaces to the user as a generic crash. A hung request never settles at all, so a loading spinner spins forever.
The last case is the most common and the least tested. fetch has no default timeout; without an AbortController, a request to a server that accepts the connection and never responds will wait until the browser or operating system gives up, which can be minutes. Users experience that as a frozen interface and reload the page, taking whatever state they had with them.
Tests miss these because the usual mock returns a resolved value or a rejected promise and nothing in between. A handler that simulates the actual failure — a network error, a slow response, a hang — is the only way to exercise the code that handles it.
Reproducible Setup
A client function with a timeout, a retry for transient failures, and error normalisation — the three behaviours these tests will verify.
// src/api/get-json.ts
export class ApiError extends Error {
constructor(public kind: 'network' | 'timeout' | 'server' | 'client' | 'parse', message: string) { super(message); }
}
export async function getJson<T>(url: string, { timeoutMs = 8000, retries = 2 } = {}): Promise<T> {
for (let attempt = 0; ; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (res.status >= 500 && attempt < retries) continue;
if (res.status >= 500) throw new ApiError('server', `Server error ${res.status}`);
if (!res.ok) throw new ApiError('client', `Request failed ${res.status}`);
try { return (await res.json()) as T; } catch { throw new ApiError('parse', 'Unexpected response'); }
} catch (err) {
if (err instanceof ApiError) throw err;
if ((err as Error).name === 'AbortError') throw new ApiError('timeout', 'Request timed out');
if (attempt < retries) continue;
throw new ApiError('network', 'Network unavailable');
} finally {
clearTimeout(timer);
}
}
}
// test/msw/server.ts
import { setupServer } from 'msw/node';
export const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Implementation
Step 1 — Produce a connection error with HttpResponse.error(). This makes fetch reject with a TypeError, exactly as a dropped connection does — not resolve with a status.
import { http, HttpResponse } from 'msw';
test('reports a network error after exhausting retries', async () => {
let calls = 0;
server.use(http.get('/api/orders', () => { calls++; return HttpResponse.error(); }));
await expect(getJson('/api/orders')).rejects.toMatchObject({ kind: 'network' });
expect(calls).toBe(3); // one attempt plus two retries
});
Step 2 — Return an HTML gateway error. Infrastructure errors do not follow your API’s error format; the client must not crash trying to parse them.
test('retries a 502 and then surfaces a server error, not a parse crash', async () => {
server.use(http.get('/api/orders', () =>
new HttpResponse('<html><body>Bad Gateway</body></html>', { status: 502, headers: { 'content-type': 'text/html' } }),
));
await expect(getJson('/api/orders')).rejects.toMatchObject({ kind: 'server' });
});
Step 3 — Recover after transient failures. The interesting retry test is the one where the service comes back: fail twice, then succeed.
test('succeeds when the service recovers within the retry budget', async () => {
let calls = 0;
server.use(http.get('/api/orders', () => (++calls < 3 ? HttpResponse.error() : HttpResponse.json([{ id: 'o1' }]))));
await expect(getJson('/api/orders')).resolves.toEqual([{ id: 'o1' }]);
});
Step 4 — Simulate a hung request and prove the timeout fires. delay('infinite') never resolves; with fake timers the test advances past the timeout instantly instead of waiting eight real seconds.
import { delay } from 'msw';
test('times out a request that never responds', async () => {
vi.useFakeTimers();
server.use(http.get('/api/orders', async () => { await delay('infinite'); return HttpResponse.json([]); }));
const pending = getJson('/api/orders', { timeoutMs: 8000, retries: 0 });
const assertion = expect(pending).rejects.toMatchObject({ kind: 'timeout' });
await vi.advanceTimersByTimeAsync(8000);
await assertion;
vi.useRealTimers();
});
Step 5 — Test the loading state with a slow response. A finite delay lets the test observe what the user sees while waiting — which is often the missing piece of an interface.
test('shows a loading indicator while orders load, then the list', async () => {
server.use(http.get('/api/orders', async () => { await delay(300); return HttpResponse.json([{ id: 'o1' }]); }));
render(<OrdersPage />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(await screen.findByRole('row', { name: /o1/ })).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
Slow responses are also the right tool for testing double submission: while the first request is still in flight, clicking the button again should not send a second one. A three-hundred-millisecond delay gives the test a window to click twice and count the requests the handler receives.
Step 6 — Assert on what the user sees for each failure. The client’s error kinds exist to drive different messages; a component test per kind proves the mapping reaches the interface.
test.each([
['network', () => HttpResponse.error(), 'You appear to be offline'],
['server', () => new HttpResponse('down', { status: 503 }), 'Something went wrong on our side'],
])('shows the %s message', async (_kind, respond, message) => {
server.use(http.get('/api/orders', respond));
render(<OrdersPage />);
expect(await screen.findByRole('alert')).toHaveTextContent(message);
});
It is worth keeping a small library of these failure handlers — networkError, gatewayHtml(502), hang, slow(ms) — in the shared test utilities, so every feature that makes requests can check its failure behaviour with a one-line override. Teams that have such a library test failure paths as a matter of course; teams that write each handler inline tend to test only the first failure somebody happened to think of.
Verification
Confirm the timeout test is load-bearing by deleting the AbortController from the client. The hang test must then fail — it will time out at the test runner’s limit rather than resolving — proving it detects a missing timeout.
npx vitest run src/api/get-json.test.ts --reporter=verbose
# ✓ reports a network error after exhausting retries
# ✓ retries a 502 and then surfaces a server error, not a parse crash
# ✓ succeeds when the service recovers within the retry budget
# ✓ times out a request that never responds
Then confirm retries are bounded by counting handler calls in each failure test. An off-by-one in the retry loop — three retries instead of two — is invisible in the result and obvious in the count.
Troubleshooting
Symptom: the network-error test resolves instead of rejecting. Diagnosis: the handler returns a response with status 0 or 500 rather than HttpResponse.error(). Fix: use HttpResponse.error() specifically — only it makes fetch reject as a real connection failure does.
Symptom: the timeout test hangs until the runner kills it. Diagnosis: fake timers were installed after the request started, so the abort timer uses real time. Fix: install fake timers before calling the client, and attach the rejection assertion before advancing.
Symptom: an unhandled rejection warning appears in the timeout test. Diagnosis: the promise rejected while the test was advancing timers, before any handler was attached. Fix: create the expect(...).rejects assertion first, then advance, then await it, as in Step 4.
Symptom: a handler from one test affects another. Diagnosis: server.use overrides persist until reset. Fix: call server.resetHandlers() in afterEach; it restores the initial handlers without restarting the server.
FAQ
Should retries use backoff in tests?
The client should back off in production; the test should not wait for it. Inject the backoff delay or its timing function so tests can set it to zero, or advance fake timers through it. Asserting on the computed schedule is covered in stubbing retry and backoff logic deterministically.
Does this work for GraphQL and other transports?
For anything over HTTP, yes — graphql.query handlers can return HttpResponse.error() and use delay the same way. WebSockets have their own MSW API, and the failure modes differ: dropped connections and reconnection logic rather than status codes.
Should end-to-end tests cover network failures too?
Sparingly. Playwright’s page.route can abort requests with a specific error code, which is useful for one or two journeys where offline behaviour is a feature. The bulk of failure handling belongs at the component and client tiers, where it is fast and precise.
What about offline detection?
navigator.onLine is a hint, not a guarantee, and offline-aware code should still handle failed requests. Test both: stub navigator.onLine to drive the offline banner, and use HttpResponse.error() to prove requests fail gracefully when the hint is wrong.
Related
- Back to External Service Simulation
- Stubbing retry and backoff logic deterministically — the timing side of retries.
- Testing loading and error states deterministically — the same failures, seen from components.
- Mocking server-sent events and streaming responses — failures mid-stream.