~ 8 min read
All About Jest, Timers, and Mocks
So you’re asserting Date.now() deltas around setTimeout, CI is green on two Node versions, and the matrix job for the middle version explodes with Expected: >= 100 and Received: 99. Nothing in your product code changed. The failure isn’t every run. That’s the kind of bug that wastes an afternoon unless you know what you’re measuring.
I hit this on npq while tightening promise throttling for package audits. We fixed it by stopping wall-clock assertions for timer behavior and driving the same scenarios with Jest fake timers. This post is the anatomy of that flake: why it happens, how the throttler actually behaves, and how I’d write the tests again from scratch.
What we’re actually testing
| What you think you’re testing | What the CPU is really doing |
|---|---|
| ”At least 100ms passed” | Integer ms from Date.now() before and after async work |
| ”setTimeout(100) means 100ms” | The host schedules a timer; wakeups aren’t aligned to your test’s two Date.now() samples |
| ”One failing Node = Node bug” | Often it’s your test assuming properties the runtime never promised |
The throttler’s job is simple: cap concurrency and enforce a minimum spacing between handled requests. The tests need to prove (1) fast work still waits for minDelay, and (2) slow work does not get an extra minDelay stacked on top. Wall-clock ms are the wrong instrument for both.
Architecture overview
flowchart TB
subgraph enqueue [Enqueue]
T[throttle fn] --> Q[queue]
Q --> PQ[processQueue]
end
subgraph run [Per item]
PQ --> W[await promiseFunction]
W --> D{minDelay > 0?}
D -->|yes| E[elapsed = Date.now - startTime]
E --> R{remainingDelay > 0?}
R -->|yes| ST[await setTimeout remainingDelay]
R -->|no| RES[resolve result]
ST --> RES
D -->|no| RES
RES --> FI[finally: setImmediate processQueue]
end
processQueue is async and re-entrant via setImmediate, but the flaky tests only cared about the Date.now / setTimeout slice in the middle: measure elapsed work, then sleep the remainder of minDelay. That’s where test time and real time diverge.
Step 1: The production path — why 99 is not always a bug
The implementation (simplified) looks like this:
const startTime = Date.now()
const result = await promiseFunction()
if (this.minDelay > 0) {
const elapsed = Date.now() - startTime
const remainingDelay = this.minDelay - elapsed
if (remainingDelay > 0) {
await new Promise((r) => setTimeout(r, remainingDelay))
}
}
Two separate issues bite you:
-
elapsedcan be1even when work feels instant. TwoDate.now()calls can straddle a clock tick. ThenremainingDelay = 100 - 1→setTimeout(..., 99). Your outer test that wraps the wholethrottle()inDate.now()still expects ≥ 100 ms. The code is doing what the math says; the test assumed “minDelay 100 ⇒ at least 100 ms wall.” -
setTimeout(100)does not promise a ≥ 100 ms delta inDate.now(). Resolution is coarse. The event loop can fire the timer such thatend - start === 99. That’s intermittent, which is why you see it on one Node version and not others — different timer internals, same brittle assertion.
So the first learning: a red test here often indicts the test, not the throttler.
Step 2: The wrong test (what we shipped first)
// ✗ Wrong — wall-clock, flaky on Node 22 CI
test('should respect minimum delay between requests', async () => {
const throttler = new PromiseThrottler()
throttler.configure(5, 100)
const startTime = Date.now()
const mockPromise = jest.fn().mockResolvedValue('result')
await throttler.throttle(mockPromise)
const endTime = Date.now()
expect(endTime - startTime).toBeGreaterThanOrEqual(100)
})
This reads clearly. It also couples the assertion to real time, OS scheduling, and Date.now() quantization. CI machines disagree with your laptop; Node 22 disagrees with 20 and 24.
Step 3: Fake timers for “must wait minDelay” (fast promise)
You want to prove: if the promise resolves immediately, the throttler still waits until minDelay worth of time has passed. Under Jest, that means controlling the clock.
// ✓ Correct — behavioral, deterministic
test('should respect minimum delay between requests', async () => {
jest.useFakeTimers({ now: Date.now() })
try {
const throttler = new PromiseThrottler()
throttler.configure(5, 100)
const mockPromise = jest.fn().mockResolvedValue('result')
const finished = throttler.throttle(mockPromise)
await Promise.resolve()
await jest.advanceTimersByTimeAsync(100)
await finished
expect(mockPromise).toHaveBeenCalledTimes(1)
} finally {
jest.useRealTimers()
}
})
Notes that matter:
useFakeTimers({ now: Date.now() })— avoids starting at epoch 0 if anything logs or compares absolute times.await Promise.resolve()— lets the microtask queue run so the mocked promise resolves and the internalsetTimeout(remainingDelay)is scheduled before you advance.finally { jest.useRealTimers() }— the same file has tests that use realsetTimeout(e.g. a deliberate 100 ms slow promise). If you leave fake timers on, you’ll break those tests in confusing ways.
Step 4: The second flake — “no extra delay” with a slow promise
After we fixed Step 3, CI still failed on the sibling test: same pattern, >= 100 vs 99, but this time the slowness came from setTimeout(..., 100) inside the mocked work, not from minDelay.
// ✗ Wrong — same wall-clock pitfall, different test name
test('should not add extra delay if promise already took longer than minDelay', async () => {
const throttler = new PromiseThrottler()
throttler.configure(5, 50)
const startTime = Date.now()
const slowPromise = jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve('result'), 100))
)
await throttler.throttle(slowPromise)
const endTime = Date.now()
expect(endTime - startTime).toBeLessThan(130)
expect(endTime - startTime).toBeGreaterThanOrEqual(100)
})
The intent: work takes ~100 ms; minDelay is 50 ms; elapsed > minDelay so the throttler should not add another 50 ms (total would drift toward ~150 ms). The upper bound (< 130) was trying to guard that. The lower bound (>= 100) still used wall-clock ms around the same flaky boundary.
Fix: run that slow work under the same fake clock and assert exactly how much virtual time passed. If the throttler incorrectly waited an extra 50 ms, you’d need advanceTimersByTimeAsync(150) to finish — or jest.now() would jump by 150 when you did advance.
// ✓ Correct — fake time must be 100ms total, not ~150ms
test('should not add extra delay if promise already took longer than minDelay', async () => {
jest.useFakeTimers({ now: Date.now() })
try {
const throttler = new PromiseThrottler()
throttler.configure(5, 50)
const slowPromise = jest.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve('result'), 100))
)
const startedAt = jest.now()
const finished = throttler.throttle(slowPromise)
await Promise.resolve()
await jest.advanceTimersByTimeAsync(100)
await finished
expect(jest.now() - startedAt).toBe(100)
expect(slowPromise).toHaveBeenCalledTimes(1)
} finally {
jest.useRealTimers()
}
})
Here jest.now() is the fake clock’s idea of time. After a single 100 ms advance, the throttle promise should be done, and the clock should read 100 ms later — not 150.
Step 5: Best practices I’d enforce in code review
-
Don’t use
Date.now()orperformance.now()to unit-test timer behavior. Use fake timers, or spy onsetTimeoutand assert delay arguments and call counts, or extract aclock/sleepdependency and inject a test double. -
Scope fake timers narrowly.
try/finallywithuseRealTimers()per test (or perdescribeif every test in the block is timer-driven). Mixing fake and real timers in one file without resetting is a common source of “passes alone, fails in full suite.” -
Flush microtasks before
advanceTimersByTimeAsync. The patternawait Promise.resolve()(sometimes twice) is boring but reliable — without it, you race the scheduler. -
When production uses
Date.now()for elapsed work, your test’s wall-clock span is not the same variable. The implementation measures time insideprocessQueuearoundawait promiseFunction(). Your test’s outerDate.now()includes queue setup, microtasks, andsetImmediatechurn. You’re not even asserting the same interval. -
Relaxing
>= 100to>= 99is a band-aid. It doesn’t fix the category error (wall clock vs. behavior), and it makes regressions easier to sneak in.
Things that surprised us
The first green fix didn’t kill CI. We only converted the “minimum delay” test. The “no extra delay” test still used real timers and the same >= 100 assertion. Same failure mode, different test name. If you fix one flaky timer test in a file, grep for Date.now(), performance.now(), and bare setTimeout expectations in the rest of the suite.
A “strict” fake-timer sequence of advance(minDelay - 1) then advance(1) is wrong if production schedules setTimeout(99). When elapsed is 1 ms, remainingDelay is 99. Advancing 99 ms already fires the timer — so asserting “not settled after 99 ms” fails. We used “advance 100 then await” for the fast-path test instead of over-fitting to a single internal delay value.
Node version skew made the flake feel like a platform bug. 20 and 24 passed; 22 failed. That’s a signal to distrust the test harness, not to bisect the Node changelog first. Timer and Date.now() coupling really is version- and load-sensitive.
Jest’s fake Date.now() and jest.now() move together when configured. That’s what makes jest.now() - startedAt === 100 a valid stand-in for “no extra 50 ms” in the slow-work scenario — the throttler’s internal Date.now()-based elapsed sees the same virtual timeline.
Where to take it from here
- Inject a clock —
({ now, schedule })passed into the throttler — so production and tests share one abstraction and you can unit-test without Jest timer mocks at all. - Property-style tests — generate random
minDelay/ work durations under fake time and assert invariants (never negative sleep, total virtual time within bounds). - Audit other packages — search your org for
toBeGreaterThanOrEqual(10andDate.now()in the same test; you’ll find more of these. - Document CI matrix policy — if you test multiple Node versions, timer tests are where you’ll feel the spread first; fake timers keep the matrix informative instead of noisy.