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.
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.
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.
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-
asyncresolver silently drops the delay.http.get(url, () => { delay(220); return HttpResponse.json(...) })compiles, runs, and waits for nothing:delayreturns 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 missingasync/awaitpair first — it is the cause more often than every other explanation combined. -
Fake timers without
shouldAdvanceTimedeadlock instead of failing. Testing Library’sfindBy*andwaitForare 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 thatadvanceTimersByTimeAsyncwill 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.
Related
- Returning HTTP 500 Errors on Demand — the status-injection sibling of this delay work
- Testing Retry and Backoff Logic Locally — where latency and failure combine into a sequence
- Writing Custom MSW Response Resolvers — the resolver mechanics these delays hook into
← Back to Error & Latency Simulation