# All About Jest, Timers, and Mocks — Liran Tal

How to use Jest fake timers, advance time safely in tests, and pair timer control with mocks and spies without flaky or misleading assertions.

Canonical: https://lirantal.com/blog/jest-timers-and-mocks

![Cover artwork for All About Jest, Timers, and Mocks](/_astro/jest-timers-and-mocks.D3IestBi_95dmV.png)

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](https://github.com/lirantal/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

```mermaid
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:

1.  **`elapsed` can be `1` even when work feels instant.** Two `Date.now()` calls can straddle a clock tick. Then `remainingDelay = 100 - 1` → **`setTimeout(..., 99)`**. Your outer test that wraps the whole `throttle()` in `Date.now()` still expects ≥ 100 ms. The code is doing what the math says; the test assumed “minDelay 100 ⇒ at least 100 ms wall.”

2.  **`setTimeout(100)` does not promise a ≥ 100 ms delta in `Date.now()`.** Resolution is coarse. The event loop can fire the timer such that `end - 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 internal `setTimeout(remainingDelay)` is scheduled before you advance.
*   **`finally { jest.useRealTimers() }`** — the same file has tests that use **real** `setTimeout` (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

1.  **Don’t use `Date.now()` or `performance.now()` to unit-test timer behavior.** Use fake timers, or spy on `setTimeout` and assert delay arguments and call counts, or extract a `clock`/`sleep` dependency and inject a test double.

2.  **Scope fake timers narrowly.** `try` / `finally` with `useRealTimers()` per test (or per `describe` if 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.”

3.  **Flush microtasks before `advanceTimersByTimeAsync`.** The pattern `await Promise.resolve()` (sometimes twice) is boring but reliable — without it, you race the scheduler.

4.  **When production uses `Date.now()` for elapsed work, your test’s wall-clock span is not the same variable.** The implementation measures time **inside** `processQueue` around `await promiseFunction()`. Your test’s outer `Date.now()` includes queue setup, microtasks, and `setImmediate` churn. You’re not even asserting the same interval.

5.  **Relaxing `>= 100` to `>= 99` is 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(10` and `Date.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.

## More writing

[All writing](/blog)

*   Sep 5, 2026

    ### [Self-Hosting Hermes Agent with Matrix E2EE on Proxmox](/blog/self-host-hermes-agent-matrix-e2ee-proxmox)

*   Aug 2, 2026

    ### [The Practical Second Brain: Building an LLM-Operated Markdown Knowledge Graph](/blog/practical-second-brain-llm-markdown-knowledge-graph)

*   Aug 1, 2026

    ### [Building a Second Brain with LLMs and AI Agents](/blog/building-second-brain-llms-ai-agents)


* * *

Liran Tal is the author of the book [Node.js Secure Coding: Defending Against Command Injection Vulnerabilities](https://www.nodejs-security.com/)

[Start Building Secure Node.js Applications](https://www.nodejs-security.com/)

 [![](/_astro/nodejs-secure-coding-book-3d-style.DiTWpzFs_Z3c7s1.png)](https://www.nodejs-security.com/)
