What timeout bugs look like
Timeout bugs are invisible in standard test suites. Your code has setTimeout(reject, 5000)
around an API call. The mock always responds instantly. The timeout is never triggered.
The code ships. In production, 1% of calls hit the 5s timeout — and your error handling
for that case throws an unhandled exception because nobody tested it.
Timeout bugs take three forms: (1) the timeout fires correctly but the error isn't surfaced to the user, (2) the timeout fires but the underlying request isn't cancelled and keeps consuming resources, or (3) the timeout value is wrong — too short, and you time out healthy requests; too long, and you hold users waiting unnecessarily. None of these are testable with an instant-returning mock.
The most dangerous timeout bug: the promise rejects after 5s, but the database query is still running. With slowdep + AbortController, you can verify that cancellation actually happens.
Setting up a slow dependency
A high p99 relative to p50 gives you a distribution where most calls finish quickly but a meaningful fraction will be slow enough to trigger timeouts. Use this to test that your timeout code path is actually exercised in your test suite.
import { withLatency } from 'slowdep'; // p50: 100ms, p99: 10s — high variance // ~1% of calls will exceed 10s, triggering your timeout const slowApi = withLatency(realApiClient.fetch, { p50: 100, p99: 10000, errorRate: 0, }); // your wrapper that enforces a timeout async function fetchWithTimeout<T>( fn: () => Promise<T>, timeoutMs: number ): Promise<T> { const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs) ); return Promise.race([fn(), timeout]); }
Forcing the slow path for deterministic tests
Probabilistic latency is great for integration tests, but for unit tests you need
the timeout to fire every time. Mock Math.random to return a
value close to 1 — this maps to the high end of the lognormal distribution, generating
a very long delay that reliably exceeds your timeout.
it('rejects with timeout error on slow response', async () => { // force Box-Muller to produce a high Z value ? very long delay let callCount = 0; jest.spyOn(Math, 'random').mockImplementation(() => { // Box-Muller needs two uniforms; alternate values return callCount++ % 2 === 0 ? 0.0001 : 0.9999; }); const slowFn = withLatency( async () => 'data', { p50: 100, p99: 10000 } ); // timeout of 500ms; Math.random forced ? delay will be >> 500ms await expect(fetchWithTimeout(slowFn, 500)) .rejects.toThrow('timeout after 500ms'); jest.restoreAllMocks(); });
The Box-Muller transform uses two uniform samples U1 and U2. When U1 is near 0, sqrt(-2 * ln(U1)) becomes very large, producing a high Z score and a very long lognormal sample.
Testing AbortController / signal
A timeout that rejects the promise but leaves the underlying request running is a resource leak.
Test that your timeout correctly aborts the in-flight request using AbortController.
async function fetchWithAbort(url: string, timeoutMs: number) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timer); } } it('aborts the request on timeout', async () => { let aborted = false; const mockFetch = withLatency( async (_url: string, opts: { signal?: AbortSignal }) => { if (opts?.signal) { opts.signal.addEventListener('abort', () => { aborted = true; }); } return new Promise(() => {}); // never resolves }, { p50: 9999, p99: 99999 } ); jest.spyOn(global, 'fetch').mockImplementation(mockFetch as typeof fetch); await expect(fetchWithAbort('/api/data', 100)) .rejects.toThrow(); expect(aborted).toBe(true); });
Testing user-facing timeout UX
The timeout path needs UI testing too. When a request times out, your app should show a fallback message, dismiss the spinner, and potentially offer a retry button. With slowdep forcing a slow response, you can test the full rendering path.
// React Testing Library example it('shows timeout error message after 5s', async () => { // force Math.random to produce a very long delay jest.spyOn(Math, 'random').mockReturnValue(0.0001); jest.useFakeTimers(); const slowApi = withLatency(fetchUserData, { p50: 100, p99: 60000 }); jest.spyOn(apiModule, 'fetchUserData').mockImplementation(slowApi); render(<UserProfile userId="123" timeout={5000} />); expect(screen.getByRole('status')).toBeInTheDocument(); // spinner visible jest.advanceTimersByTime(5001); await waitFor(() => { expect(screen.getByText(/request timed out/i)).toBeInTheDocument(); expect(screen.queryByRole('status')).not.toBeInTheDocument(); // spinner gone }); jest.useRealTimers(); jest.restoreAllMocks(); });
Timeout strategy comparison
Not all timeout strategies are equal. Understanding the tradeoffs helps you choose what to implement — and what to test.
| strategy | how it works | tradeoffs |
|---|---|---|
| fixed timeout | reject after N ms, same for every call | simple — too short: healthy requests fail; too long: users wait |
| dynamic timeout | timeout = p99 + buffer, measured from live metrics | adapts to load — more complex, needs metrics feedback loop |
| hedged request | after p50 ms, send a second request; use whichever returns first | eliminates tail latency — doubles load on server, use carefully |
| deadline propagation | pass a remaining deadline down the call stack | prevents cascading waits — requires ctx threading through all calls |
slowdep's lognormal distribution lets you test all these strategies: set up a bimodal test where 99% of calls are fast and 1% hit the tail, then verify each strategy handles the tail correctly.