The problem

The false confidence of flat delays

When you write await new Promise(r => setTimeout(r, 200)) in a test, you feel like you've done something responsible. You've added latency. You've simulated a slow dependency. But you haven't — you've added a fixed delay. Every single call takes exactly 200ms.

That means your retry logic looks great because it triggers at exactly 200ms. Your timeout is never hit because the mock never drifts. Your loading state flashes for precisely 200ms, every time. The test passes. The code ships. Production breaks — because production doesn't have a timer that fires at 200ms. It has a distribution: most calls are 5ms, and one in a hundred takes 2 seconds.

// what your test does
const mockDB = jest.fn(async () => {
  await new Promise(r => setTimeout(r, 200));
  return { id: 1, name: 'Alice' };
});

// what production actually does
// p50: 5ms, p99: 200ms, occasionally 800ms+
// your timeout of 250ms fails 1 in 200 requests
False confidence

A test that always takes exactly 200ms cannot catch bugs that only appear when 1% of calls take 2 seconds. Fixed delays create a false sense of security.

Latency reality

What real latency looks like

Real dependency latency is bimodal and right-skewed. Most calls fall on a fast path: the database connection is already warm, the query hits an index, the cache is hot. These calls complete in single-digit milliseconds.

A small fraction of calls hit a slow path: a GC pause on the database server, a network retransmission, a cold connection being opened, a lock wait, a noisy neighbor on the same host. These calls take 10x, 100x longer. The result is a distribution with a sharp peak on the left and a long tail on the right — a lognormal distribution.

setTimeout — flat distribution
0ms ——————————————————————— 200ms ——————————————————————— 400ms
slowdep — lognormal distribution (realistic)
0ms ——— fast path (most calls) ————————— p99 (1%) —————— tail (rare)
Hidden bugs

The bugs setTimeout hides

Fixed delays don't just fail to catch bugs — they actively hide them. Here are three categories of bugs that pass silently with setTimeout but surface immediately with realistic latency:

1
Retry logic that only triggers at the configured threshold
If your retry fires after 250ms and your mock always takes 200ms, retries never trigger. In production, 1% of calls take 800ms — and your retry logic is untested at that latency. With slowdep's lognormal distribution, some test calls will naturally exceed 250ms and exercise the retry path.
2
Timeouts that are either never triggered or always triggered
Set your mock delay to 200ms and your timeout to 250ms: timeout never fires. Set mock to 300ms and timeout to 250ms: timeout always fires. Neither tells you whether your timeout is calibrated correctly for realistic tail latency. slowdep samples across the full distribution, so some calls will hit the timeout and most won't — exactly like production.
3
Loading states that flash for exactly 200ms and never vary
Your UI shows a spinner while a request is in-flight. With setTimeout, the spinner always shows for exactly the same duration. Real users see it for 5ms (barely visible) or 2 seconds (feels broken). Testing with realistic latency reveals whether your skeleton screens, progress indicators, and timeout fallbacks actually work.
The solution

What slowdep does differently

slowdep samples from a lognormal distribution parameterized by p50 and p99 — the two numbers you can actually measure from your APM tool. Most calls are fast (near p50), a small fraction are slow (near p99), and rare calls exceed that. This matches what production looks like.

The math: mu = ln(p50), sigma = (ln(p99) - mu) / 2.326, delay sampled from exp(mu + sigma * Z) where Z is a standard normal variate.

import { withLatency } from 'slowdep';

// wrap your db client once — done
const db = withLatency(realDbClient, 'postgres');
// p50: 5ms, p99: 200ms — lognormal distribution
// some calls: 3ms. some: 180ms. rare: 400ms+

const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
// the setTimeout equivalent — manual and wrong
const db = {
  query: async (sql, params) => {
    await new Promise(r => setTimeout(r, 200)); // always 200ms
    return realDbClient.query(sql, params);
  }
};
// every call: exactly 200ms
// p99 behavior: never exercised
// retry / timeout bugs: hidden
Scope

When setTimeout is fine

setTimeout isn't always wrong. If you just need some delay in a smoke test — you're not testing retry logic, you're not testing timeouts, you just need the function to be async — setTimeout works fine.

Use slowdep when the shape of the latency distribution matters to the correctness of your code. That includes: retry logic, timeout calibration, circuit breaker configuration, backoff jitter, loading state UX, and any code whose behavior depends on whether a call takes 5ms or 500ms.

scenariosetTimeoutslowdep
smoke test — just needs asyncfinealso fine
testing retry logichides bugscatches bugs
testing timeout calibrationmisleadingaccurate
testing circuit breakerinsufficientcorrect
testing loading UXlimitedrealistic
CI integration testingpartialrecommended
Quick migration

Replace await new Promise(r => setTimeout(r, 200)) with withLatency(fn, { p50: 5, p99: 200 }) and wrap your dependency. One line change, immediate improvement.