The problem

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.

Uncancelled requests

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.

Setup

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]);
}
Determinism

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();
});
How it works

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.

Cancellation

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);
});
UX

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();
});
Strategy

Timeout strategy comparison

Not all timeout strategies are equal. Understanding the tradeoffs helps you choose what to implement — and what to test.

strategyhow it workstradeoffs
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 covers all three

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.