The problem

Why flat delays lie

The standard approach to simulating a slow dependency in tests is await new Promise(r => setTimeout(r, 100)). It's simple, readable, and wrong.

The problem isn't the 100ms — it's the flatness. Every single simulated call takes exactly 100ms. No variance. No jitter. No occasional 800ms outlier. Real production dependencies don't behave this way. A Postgres query might take 4ms 95% of the time, but every few thousand queries it takes 300ms. That 300ms case is where your timeout fires, your retry budget runs out, or your circuit breaker trips.

When you test with a flat delay, you never see those cases. You ship code that handles the fast path perfectly and the slow path not at all. The first time your code encounters real tail latency in production, it fails.

The false confidence problem

A test suite that passes with setTimeout(fn, 100) as the simulated database gives you false confidence. It proves your code works when the database is always 100ms — a scenario that never occurs in reality.

The model

The lognormal distribution

Real latency follows a lognormal distribution: a shape where the logarithm of the variable is normally distributed. This produces three observable characteristics that match real systems:

  • A floor near zero. Latency cannot be negative, and there's a practical lower bound (network RTT, CPU time).
  • Most values cluster near the median. The typical case is fast. Your p50 is what users experience most of the time.
  • A long right tail. A small fraction of requests are much slower than the median — GC pauses, lock contention, cold caches, network retransmits.

slowdep fits a lognormal distribution using two parameters you already know: p50 and p99. The math:

// Fit the distribution from p50 and p99
mu    = ln(p50)
sigma = (ln(p99) - mu) / 2.326   // 2.326 = z-score of the 99th percentile

// Sample using Box-Muller transform
z     = sqrt(-2 * ln(u1)) * cos(2p * u2)   // standard normal
delay = exp(mu + sigma * z)               // lognormal sample
delay = min(delay, p99 * 3)              // cap at 3x p99

The cap at p99 * 3 prevents the theoretical infinite tail from producing absurdly long delays in tests. It's a practical approximation of p999 behavior.

Percentiles

p50, p95, p99 explained

Percentiles describe the distribution of latency across a population of requests. "p99 = 200ms" means that 99 out of 100 requests complete in under 200ms — and 1 out of 100 takes 200ms or longer.

percentilemeaningpractical significance
p50 Half of requests are faster, half are slower The "typical" experience. What most users see most of the time.
p95 95% of requests are faster The "somewhat unlucky" case. A useful SLO target that covers most users.
p99 99% of requests are faster The "danger zone". 1 in 100 calls hits this — at 100 RPS, that's one per second. If your timeout is less than p99, calls will time out in normal operation.
p999 99.9% of requests are faster Rare but not negligible at high traffic. At 10,000 RPS, roughly 10 requests per second hit this tier. slowdep caps at p99 × 3 as an approximation.
Why p99 is the key parameter

Setting your timeout to 2× the p50 is not enough. A Postgres query with p50=5ms and p99=200ms will time out 1% of the time if your timeout is 10ms. slowdep surfaces this mismatch during testing rather than in production.

Fault injection

Error injection

Real dependencies fail transiently — network hiccups, connection pool exhaustion, brief unavailability during deploys. The errorRate option tells slowdep to reject a fraction of calls with a transient error before the underlying function is called.

const slowCharge = withLatency(charge, {
  p50: 200,
  p99: 2000,
  errorRate: 0.01  // 1% of calls reject with a transient error
})

When the error rate fires, the promise rejects with new Error('Simulated transient error'). The underlying function is not invoked. This is intentional — it models the scenario where the dependency is unreachable, not where it returned an application-level error.

Why this matters: most applications have retry logic for application errors (400s, 404s) but forget to handle transport-level failures. errorRate forces your retry and circuit-breaker code to prove it works.

Configuration

Presets vs custom profiles

Presets are named latency profiles derived from production observations of common services. Pass a string like 'postgres' or 'redis' and you get realistic p50/p99 values and an appropriate error rate without any configuration.

// Preset — fast to write, good defaults
const slowQuery = withLatency(query, 'postgres')

Custom profiles are for when you have your own production metrics and want to match them exactly. If your Postgres p99 is 400ms instead of 200ms, use a custom profile.

// Custom profile — matches your actual production numbers
const slowQuery = withLatency(query, { p50: 6, p99: 400, errorRate: 0.002 })

The rule of thumb: start with a preset to get something reasonable immediately, then switch to a custom profile when you have real production percentile data to match against. Presets are starting points, not ground truth.