Side-by-side comparison
The fundamental difference: setTimeout produces a degenerate distribution with a single value. slowdep produces a lognormal distribution that matches how real production dependencies behave.
| feature | setTimeout | slowdep |
|---|---|---|
| delay distribution | fixed — always exactly N ms | lognormal — fast most of the time, long tail |
| p50 accuracy | set a fixed delay near p50 | p50 is a first-class parameter |
| p99 realism | cannot model tail behavior | p99 is a first-class parameter |
| variance across calls | zero — all calls identical | natural variance from distribution |
| error injection | manual — throw inside the promise | errorRate option — probabilistic errors |
| TypeScript support | native | full — preserves generics |
| preserves function signature | requires manual wrapping | automatic — same args, same return type |
| zero setup | built in | npm install slowdep |
| realistic for retry testing | no — retries fire at exactly the threshold | yes — natural distribution exercises retry paths |
| realistic for timeout testing | no — timeouts always fire or never fire | yes — some calls exceed timeout, most don't |
The same test, written two ways
Here's an integration test for a user service. Written with setTimeout first, then with slowdep. The tests look similar, but they catch fundamentally different bugs.
// with setTimeout — looks fine, hides bugs describe('UserService', () => { const mockDb = { findUser: jest.fn(async (id: string) => { await new Promise(r => setTimeout(r, 200)); // always 200ms return { id, name: 'Alice' }; }), }; it('fetches a user', async () => { const user = await new UserService(mockDb).getUser('1'); expect(user.name).toBe('Alice'); }); // bugs this test CANNOT catch: // ? Timeout of 250ms fires when DB takes 300ms — never happens here // ? Retry fires when DB is slow — always exactly 200ms, retry never triggers // ? Circuit breaker trips on 30% error rate — no errors injected // ? Connection pool exhausted by 1% slow calls — no variance });
// with slowdep — catches real latency bugs import { withLatency } from 'slowdep'; describe('UserService', () => { const mockDb = { findUser: withLatency( async (id: string) => ({ id, name: 'Alice' }), { p50: 5, p99: 200, errorRate: 0.02 } // 2% error rate ), }; it('fetches a user under realistic latency', async () => { const user = await new UserService(mockDb).getUser('1'); expect(user.name).toBe('Alice'); }); // bugs this test CAN catch: // ? Timeout of 250ms fires — some calls will take 200ms+, timeout may trigger // ? Retry fires on errors — 2% errorRate means retries are exercised // ? Circuit breaker trips — combine with higher errorRate to test threshold // ? P99 behavior — naturally sampled, loading states tested realistically });
What each approach catches
The difference isn't cosmetic. Each approach finds a different class of bugs. Tests written with setTimeout give you false confidence about code that will break under realistic production latency.
Migrating from setTimeout
The migration is mechanical. Find every await new Promise(r => setTimeout(r, N))
that wraps a dependency call, and replace it with withLatency.
// before const mockQuery = jest.fn(async (sql) => { await new Promise(r => setTimeout(r, 200)); return FIXTURE_DATA; }); // after — one import, one wrap import { withLatency } from 'slowdep'; const mockQuery = withLatency( jest.fn(async (sql) => FIXTURE_DATA), { p50: 5, p99: 200 } // the 200ms you had before is now your p99 );
If you had setTimeout(r, 200), your intent was probably "simulate a slow DB call." Use that 200ms as your p99 and pick a realistic p50 (for Postgres, that's 5ms). The result is far more representative.