Simulating Network Latency in MSW

Your loading skeletons never appear locally because the mock answers in under a millisecond, so nobody notices when a spinner is missing, flashes, or never clears. This page shows how to add controllable delay to MSW handler registration — fixed, random, and realistically distributed — without turning a two-second test suite into a four-minute one.

Context: why the default is instant

MSW resolves handlers in-process. In the browser the Service Worker answers from the same event loop that made the request; under setupServer in Node there is not even a worker boundary to cross. Either way the round trip is a function call, so response time is effectively zero and every asynchronous state in the application collapses instantly to its resolved form.

That is a genuine feature — it is why an MSW suite runs so much faster than one talking to a container, as the MSW versus WireMock comparison sets out. It is also why a whole category of bug ships undetected: request waterfalls that only hurt when each hop costs 200 ms, spinners that render for zero frames and then flash, and race conditions between two in-flight requests that only interleave when one is slower than the other.

What a delay reveals in the render timeline Two horizontal timelines. With an instant mock the component goes straight from mount to resolved content, so the loading branch occupies no time and is never observed. With a 220 millisecond delay the same component spends a visible span in the loading state before resolving, exposing whether the skeleton renders, whether it flashes, and whether it is replaced correctly. Instant mock — the loading branch has zero width mount resolved content loading ≈ 0 ms await delay(220) — the loading branch becomes observable mount loading state — 220 ms resolved content Does the skeleton render? Does it flash? Is it replaced? Did a second request overtake the first? The bug is not in the resolved state — it is in the span the instant mock erases.

Solution

1. Await delay inside the resolver

MSW 2.x exports delay from the package root. The resolver must be async and the call must be awaited, or the delay is created and discarded:

// src/mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw';

export const handlers = [
  http.get('https://api.example.com/orders', async () => {
    await delay(220);
    return HttpResponse.json([
      { id: 'ord_1', status: 'paid', total: 4250 },
      { id: 'ord_2', status: 'pending', total: 1899 },
    ]);
  }),
];

delay accepts three forms. A number is a fixed millisecond wait. The string 'real' samples MSW’s own plausible distribution. The string 'infinite' never resolves, which is the correct way to model a hard timeout — it is not the same as a very large number, because an infinite delay produces no timer that a fake-timer runner has to advance past.

Choosing a delay form A decision tree starting from the question of what the test is trying to prove. Proving a loading state renders leads to a fixed millisecond delay. Proving the client survives the slow tail leads to a sampled log-normal delay. Proving the client abandons a request leads to the infinite delay. Merely making the dev server feel plausible leads to MSW's own realistic mode. What must this delay prove? A loading state renders and clears The client survives the slow tail The client gives up on its own The dev server merely feels plausible delay(220) fixed, deterministic, assertable delay(sampleLatency(180)) log-normal body plus tail delay('infinite') no timer for a fake clock to skip delay('real') MSW's own distribution

2. Shape the delay so the tail exists

A constant is fine for making a spinner visible and useless for finding timeout bugs, because no request ever lands in the tail. Sampling a log-normal distribution costs four lines and produces the shape real dependencies have — a dense body near the median with a thinning right tail:

// src/mocks/latency.ts
/**
 * Sample a log-normal latency in milliseconds.
 * `median` is the p50 you observe in production; `sigma` widens the tail
 * (0.3 is a tight, well-behaved service; 0.7 is a noisy one).
 */
export function sampleLatency(median: number, sigma = 0.4): number {
  // Box–Muller transform: two uniforms in, one standard normal out.
  const u1 = Math.random() || Number.EPSILON;
  const u2 = Math.random();
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return Math.round(median * Math.exp(sigma * z));
}

/** One multiplier so CI can run the same profiles in a fraction of the time. */
export function scaled(ms: number): number {
  const factor = Number(process.env.MOCK_DELAY_FACTOR ?? '1');
  return Math.round(ms * (Number.isFinite(factor) && factor > 0 ? factor : 1));
}

Used in a handler, the call site stays readable:

import { http, HttpResponse, delay } from 'msw';
import { sampleLatency, scaled } from './latency';

http.get('https://api.example.com/orders', async () => {
  await delay(scaled(sampleLatency(180, 0.4)));
  return HttpResponse.json([{ id: 'ord_1', status: 'paid', total: 4250 }]);
});

Because sampleLatency uses Math.random, consecutive runs differ. That is the point for exploratory work, but it makes a failing CI run hard to reproduce. Feed it a seeded generator when determinism matters — the same discipline described in deterministic seed management applies to latency exactly as it does to data.

3. Give different endpoints different profiles

Uniform latency across every endpoint is its own kind of lie: a cached lookup and a payment authorisation do not cost the same. Map endpoints to profiles once and the waterfall in your app becomes visible:

// src/mocks/latency-profiles.ts
export const LATENCY_MS: Record<string, { median: number; sigma: number }> = {
  'GET /session':          { median: 40,  sigma: 0.25 },  // edge-cached
  'GET /orders':           { median: 180, sigma: 0.40 },  // primary datastore
  'GET /orders/:id/items': { median: 210, sigma: 0.45 },
  'POST /payments':        { median: 850, sigma: 0.60 },  // third-party hop
  'GET /recommendations':  { median: 640, sigma: 0.80 },  // slow, noisy service
};

With those numbers in place a page that fetches session, then orders, then items sequentially takes over 400 ms before anything renders — and the fix (parallelising the independent calls) becomes obvious in a way it never is at zero latency.

The waterfall that only appears once latency is modelled Two request waterfalls drawn on the same time axis. The sequential version chains session, orders and items end to end for a total of about 430 milliseconds before first paint. The parallel version issues orders and recommendations at the same time as soon as the session resolves, cutting the time to first paint to roughly 250 milliseconds. Sequential — each request waits for the last session GET /orders — 180 ms GET /orders/:id/items — 210 ms first paint ≈ 430 ms Parallel — independent calls issued together session GET /orders — 180 ms GET /recommendations — 205 ms first paint ≈ 250 ms At zero latency both layouts finish in the same instant, so the waterfall is invisible and never gets fixed.

4. Keep the suite fast

Real delay in hundreds of unit specs is unacceptable. Two levers together reduce the cost to near zero without maintaining a second set of numbers.

The first is the MOCK_DELAY_FACTOR multiplier already shown — CI exports 0.05 and every profile shrinks proportionally, preserving the relative ordering that makes waterfall assertions meaningful. The second is fake timers, which remove wall-clock cost entirely:

// src/features/orders/OrdersList.test.tsx
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { OrdersList } from './OrdersList';

beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());

it('shows the skeleton before the rows arrive', async () => {
  render(<OrdersList />);

  expect(screen.getByTestId('orders-skeleton')).toBeInTheDocument();

  await vi.advanceTimersByTimeAsync(300);

  expect(screen.queryByTestId('orders-skeleton')).not.toBeInTheDocument();
  expect(await screen.findByText('ord_1')).toBeInTheDocument();
});

shouldAdvanceTime: true is the option that makes this work with Testing Library: without it, the library’s own waitFor polling also freezes and the test deadlocks rather than failing. This spec asserts the skeleton and its removal, which is the pair that catches both a missing loading state and one that never clears — and it costs microseconds because the 220 ms never actually elapses.

Verification

Confirm the delay is real, then confirm it is cheap:

# The handler genuinely waits — expect ~0.22s, not ~0.00s
node -e "
  const t = Date.now();
  fetch('http://localhost:5173/api/orders')
    .then(() => console.log('elapsed', (Date.now() - t) / 1000, 's'));
"

# The suite is still fast with faults enabled
MOCK_DELAY_FACTOR=0.05 npx vitest run --reporter=basic

The first command should print an elapsed time close to your configured median. If it prints something near zero, the resolver is not awaiting — see the first gotcha below. The second should complete in roughly the same wall-clock time as the un-delayed suite; if it takes noticeably longer, a spec is using real timers where it should be faking them.

Gotchas and edge cases

  • A non-async resolver silently drops the delay. http.get(url, () => { delay(220); return HttpResponse.json(...) }) compiles, runs, and waits for nothing: delay returns an unawaited promise and the response is returned immediately. TypeScript will not catch it because returning a response from a synchronous resolver is legal. If a delay appears to do nothing, check for the missing async/await pair first — it is the cause more often than every other explanation combined.

  • Fake timers without shouldAdvanceTime deadlock instead of failing. Testing Library’s findBy* and waitFor are built on timers. Freeze the clock completely and they never poll again, so the test hangs until the runner’s global timeout rather than reporting a useful assertion failure. Always pass { shouldAdvanceTime: true }, or advance the clock explicitly between every await.

  • delay('infinite') and a very large number behave differently under fake timers. A 120-second delay is a pending timer that advanceTimersByTimeAsync will happily jump past, resolving the request you meant to leave hanging. 'infinite' creates no timer at all, so the request stays pending no matter how far the clock advances — which is what a genuine timeout test needs. Use it whenever the client is supposed to give up on its own.


← Back to Error & Latency Simulation