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
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.
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.
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:
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
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.
| scenario | setTimeout | slowdep |
|---|---|---|
| smoke test — just needs async | fine | also fine |
| testing retry logic | hides bugs | catches bugs |
| testing timeout calibration | misleading | accurate |
| testing circuit breaker | insufficient | correct |
| testing loading UX | limited | realistic |
| CI integration testing | partial | recommended |
Replace await new Promise(r => setTimeout(r, 200)) with withLatency(fn, { p50: 5, p99: 200 }) and wrap your dependency. One line change, immediate improvement.