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.

Four kinds of network failure and the assumption each breaks A connection error rejects fetch instead of returning a status, a gateway error returns HTML that breaks JSON parsing, a slow response exposes missing loading states, and a hung request never settles unless the code imposes a timeout. connection error fetch rejects — no status at all breaks: "check response.ok" gateway error 502 with an HTML body breaks: "error bodies are JSON" slow response resolves after seconds breaks: "loading is instant" hung request never settles breaks: "fetch has a timeout"
Each failure breaks a different assumption, so each needs its own test.

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();
});
Testing a timeout without waiting for it The handler never responds, the client's AbortController timer is advanced with fake timers, the abort fires, and the client rejects with a timeout error — all in milliseconds of real time. fetch starts abort timer set handler hangs delay('infinite') advance 8000ms fake timers timeout error AbortError mapped eight seconds of simulated waiting, a few milliseconds of real time
Attaching the assertion before advancing the timers avoids an unhandled rejection in between.

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.

Which failures should be retried Connection errors and 5xx responses are transient and worth retrying, while 4xx responses and parse errors are permanent for that request and retrying them only delays the error. retry connection errors 502, 503, 504 fail immediately 400, 401, 403, 404, 422 unparseable bodies
Counting handler calls is what proves the classification: retried failures show three calls, permanent ones show one.

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.