Testing Asynchronous Node.js: How to Control Time and Tame Race Conditions
Your async tests pass 9 times out of 10 and fail in CI for no reason. The real bug is in how you test time-dependent code. Here is how to replace setTimeout with deterministic clocks, control promise ordering, and eliminate flakiness from every async test in your suite.
The test passed on your machine. It passed in the PR preview. It passed on the first CI run. Then the deploy failed because the same test flaked on the third retry, the pipeline rejected the merge, and you spent forty-five minutes clicking “Re-run” while the on-call engineer sent increasingly pointed Slack messages. The offending code was a three-line retry loop with a setTimeout.
This is the single most frustrating category of test failure in Node.js. The test is not testing what you think it is testing. It is testing that the wall clock happened to tick long enough for an async callback to fire before the assertion ran. That coupling to real time is what makes the test flaky, slow, and untrustworthy.
This post covers the three techniques that eliminate async flakiness for good: fake timers, controlled promise resolution, and deterministic race-condition reproduction. The patterns work with any test framework (Node.js built-in test runner, Vitest, Jest, Mocha) and they turn a suite full of setTimeout(5000) tests into sub-millisecond assertions.
The real problem: coupling tests to wall-clock time
Consider the most common async pattern in Node.js: a function that retries an operation with backoff.
async function fetchWithRetry(
url: string,
options: { maxRetries: number; baseDelayMs: number }
): Promise<Response> {
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
try {
const res = await fetch(url);
if (res.ok) return res;
throw new Error(`HTTP ${res.status}`);
} catch (err) {
if (attempt === options.maxRetries) throw err;
await sleep(options.baseDelayMs * Math.pow(2, attempt));
}
}
throw new Error('unreachable');
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
A naive test for this function looks something like:
// DO NOT DO THIS
it('retries after a failure', async () => {
const start = Date.now();
await fetchWithRetry('http://localhost:9999/nope', {
maxRetries: 2,
baseDelayMs: 1000,
});
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThan(3000); // base*1 + base*2 = 3 seconds
});
This test takes three real seconds to run. It also breaks whenever the test environment is under CPU pressure, because Date.now() is not perfectly aligned with setTimeout resolution. Run it on a busy CI runner and the elapsed time could be 3200 ms. Run it under load and it could be 4500 ms. The assertion threshold drifts, you bump it to toBeGreaterThan(2500) to pass, and now the test is measuring nothing useful.
The fix is to decouple the test from wall-clock time entirely. Replace setTimeout and Date.now with a fake clock that you control explicitly.
Technique 1: Fake timers
A fake timer replaces setTimeout, setInterval, setImmediate, and Date.now with synchronous versions that advance only when your test says so. Instead of waiting three seconds for a retry, you advance the clock by three seconds in a single line, and every pending timer fires immediately.
Using the Node.js built-in test runner with fake timers
Node.js does not ship a fake-timer utility in its standard library, but the sinon package’s useFakeTimers works perfectly with the built-in test runner. Vitest ships its own via vi.useFakeTimers(), and Jest has jest.useFakeTimers(). The concept is identical across all three.
Here is the same retry test, rewritten with fake timers:
import { describe, it, mock, before, after } from 'node:test';
import assert from 'node:assert/strict';
import sinon from 'sinon';
describe('fetchWithRetry with fake timers', () => {
let clock: sinon.SinonFakeTimers;
before(() => {
clock = sinon.useFakeTimers();
});
after(() => {
clock.restore();
});
it('retries with exponential backoff on failure', async () => {
const fetchMock = mock.fn();
fetchMock.mock.mockImplementation(() =>
Promise.reject(new Error('network error'))
);
// Start the retry (this will hit the first await sleep())
const promise = fetchWithRetry('http://example.com', {
maxRetries: 2,
baseDelayMs: 1000,
});
// Advance past first backoff (1s)
await clock.tickAsync(1000);
assert.strictEqual(fetchMock.mock.calls.length, 2);
// Advance past second backoff (2s)
await clock.tickAsync(2000);
assert.strictEqual(fetchMock.mock.calls.length, 3);
// After max retries, the promise rejects
await assert.rejects(promise, /network error/);
});
});
The test completes in milliseconds. It verifies that each retry happens at the exact right time: 1 second after the first failure, 2 seconds after the second. It does not depend on wall-clock timing, CPU scheduling, or CI runner load.
The key detail is clock.tickAsync(). This advances the fake clock and resolves any pending promises that had setTimeout callbacks queued. The tick happens synchronously from the test’s perspective, but pending microtasks (promise .then() handlers) are drained before control returns to your test code.
What fake timers replace
When you call sinon.useFakeTimers(), the following globals are replaced:
setTimeout/clearTimeoutsetInterval/clearIntervalsetImmediate/clearImmediateDate.now()Dateconstructor (optional, configurable)performance.now()(optional)
Every piece of code under test that uses any of these is now operating on your virtual clock. Nothing touches real time unless you explicitly allow it.
The one thing fake timers cannot fake
Fake timers cannot control real I/O. A fetch() call, a database query, or a file read still takes real time because those operations go through the kernel, not through JavaScript’s timer queue. For tests that combine timers with I/O, you have two options:
- Mock the I/O layer so it returns immediately, then drive the timers with fake clocks.
- Use real timers but timeout-aware assertions with generous bounds and retry logic.
Option 1 is almost always better. If your code waits for a fetch() response and then starts a timer, mock the fetch() to return an immediate response, then test the timer logic with fake timers separately.
Technique 2: Controlled promise resolution
Fake timers solve time-dependent flakiness. But there is another category of async flakiness that fake timers do not touch: race conditions between promises.
Consider a function that fetches data from two sources and returns whichever response arrives first:
function fetchRace<T>(sources: (() => Promise<T>)[]): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false;
for (const source of sources) {
source()
.then((result) => {
if (!settled) {
settled = true;
resolve(result);
}
})
.catch((err) => {
if (!settled) {
settled = true;
reject(err);
}
});
}
});
}
Testing this is a nightmare without control over promise ordering. Which source “wins” depends on the unpredictable timing of I/O. If both sources resolve immediately (e.g., from cache), the race is decided by microtask queuing order, which varies between V8 versions and even between runs.
The solution is a deferred promise pattern that gives you explicit control over when each promise resolves:
class Deferred<T> {
promise: Promise<T>;
resolve!: (value: T | PromiseLike<T>) => void;
reject!: (reason: unknown) => void;
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
With this utility, you can write a deterministic test for fetchRace:
it('resolves with the fastest source', async () => {
const slow = new Deferred<string>();
const fast = new Deferred<string>();
const racePromise = fetchRace([
() => slow.promise,
() => fast.promise,
]);
// Fast source resolves first
fast.resolve('fast data');
// The race should resolve with 'fast data' immediately
const result = await racePromise;
assert.strictEqual(result, 'fast data');
// Slow source resolves later but no one cares
slow.resolve('slow data');
});
it('rejects when the first source rejects', async () => {
const source1 = new Deferred<string>();
const source2 = new Deferred<string>();
const racePromise = fetchRace([
() => source1.promise,
() => source2.promise,
]);
source1.reject(new Error('source 1 failed'));
await assert.rejects(racePromise, /source 1 failed/);
// source2 is still pending - the function should not leak it
source2.resolve('late data');
});
These tests are fully deterministic. They do not depend on which promise resolves first in the JavaScript microtask queue. The test explicitly controls the order of resolution and verifies behavior at each step.
Canceling leaked promises
One additional benefit of controlled promises: they catch promise leaks. If fetchRace did not properly ignore the second source after the first resolved, the second source2.resolve('late data') would trigger a hidden resolution. With a simple assertion helper, you can verify that the second promise never settles:
it('ignores slower sources after the race is settled', async () => {
const source1 = new Deferred<string>();
const source2 = new Deferred<string>();
const racePromise = fetchRace([() => source1.promise, () => source2.promise]);
source1.resolve('winner');
const result = await racePromise;
assert.strictEqual(result, 'winner');
// Give microtasks a chance to run, then verify no side effects
await new Promise(process.nextTick);
// If fetchRace leaked source2, this would cause an unhandled rejection
// or fire a then handler we did not expect. The test silently passes
// either way, which is a problem. Add a spy to catch it.
});
// Better: wrap each source in a spy
function trackableSource(name: string, deferred: Deferred<string>) {
return async () => {
const result = await deferred.promise;
return result;
};
}
The deferred pattern is the most versatile tool in your async testing toolbox. Use it wherever your code coordinates multiple async operations: Promise.race, Promise.all, Promise.allSettled, async queues, concurrency limiters, and pub/sub event handlers.
Technique 3: Reproducing race conditions deterministically
The hardest async bugs to fix are the ones that only happen in production under a specific interleaving of async operations. A typical example: a cache update races with a cache read, and a stale value lives in memory for five extra minutes.
Here is a simplified version:
class Cache<T> {
private store = new Map<string, { value: T; expiresAt: number }>();
async get(key: string): Promise<T | undefined> {
const entry = this.store.get(key);
if (entry && entry.expiresAt > Date.now()) {
return entry.value;
}
return undefined;
}
async set(key: string, value: T, ttlMs: number): Promise<void> {
// Simulate async write (e.g., writing to Redis)
await sleep(5);
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
async getOrFetch(
key: string,
fetcher: () => Promise<T>,
ttlMs: number
): Promise<T> {
const cached = await this.get(key);
if (cached) return cached;
const value = await fetcher();
await this.set(key, value, ttlMs);
return value;
}
}
The bug: two concurrent calls to getOrFetch with the same key can both see a cache miss, both call fetcher(), and both write to the store. The second write may complete before the first, leaving stale data in the cache. Or they both write, and the TTL is reset twice.
To reproduce this deterministically, use deferred promises to control the interleaving:
it('handles concurrent getOrFetch calls without duplicate fetches', async () => {
const cache = new Cache<string>();
const firstFetch = new Deferred<string>();
const secondFetch = new Deferred<string>();
let fetchCount = 0;
const fetcher = async () => {
fetchCount++;
if (fetchCount === 1) return firstFetch.promise;
return secondFetch.promise;
};
// Start both calls. Neither has resolved its fetch yet.
const result1 = cache.getOrFetch('key', fetcher, 1000);
const result2 = cache.getOrFetch('key', fetcher, 1000);
// Let the first fetch resolve
firstFetch.resolve('first value');
// The first call's set() is now in progress, but we control its timing
// by replacing sleep(5) with our own...
// This is where it gets tricky - we need to control the set() timing too.
// The real fix is to inject the timer/sleep dependency.
});
The lesson here: to test race conditions deterministically, you need to inject every async boundary as a dependency. If your code calls sleep() internally, the test cannot control it unless sleep is replaceable.
The dependency injection pattern for async testing
The production-ready version of Cache accepts its clock and sleep function as dependencies:
interface Clock {
now(): number;
sleep(ms: number): Promise<void>;
}
class CacheWithClock<T> {
private store = new Map<string, { value: T; expiresAt: number }>();
constructor(private clock: Clock) {}
async get(key: string): Promise<T | undefined> {
const entry = this.store.get(key);
if (entry && entry.expiresAt > this.clock.now()) {
return entry.value;
}
return undefined;
}
async set(key: string, value: T, ttlMs: number): Promise<void> {
await this.clock.sleep(5);
this.store.set(key, { value, expiresAt: this.clock.now() + ttlMs });
}
async getOrFetch(
key: string,
fetcher: () => Promise<T>,
ttlMs: number
): Promise<T> {
const cached = await this.get(key);
if (cached) return cached;
const value = await fetcher();
await this.set(key, value, ttlMs);
return value;
}
}
Now the test controls every async boundary:
it('prevents duplicate fetches for concurrent cache misses', async () => {
const clock = sinon.useFakeTimers();
const cache = new CacheWithClock({ now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)) });
// Replace with controlled versions
const deferredFetcher1 = new Deferred<string>();
const deferredFetcher2 = new Deferred<string>();
let callCount = 0;
const fetcher = () => {
callCount++;
return callCount === 1 ? deferredFetcher1.promise : deferredFetcher2.promise;
};
const promise1 = cache.getOrFetch('key', fetcher, 1000);
const promise2 = cache.getOrFetch('key', fetcher, 1000);
// Both calls are now awaiting fetcher(). Resolve the first one.
deferredFetcher1.resolve('hello');
// First call is now waiting on clock.sleep(5). Tick past it.
await clock.tickAsync(5);
assert.strictEqual(callCount, 1); // Second call should NOT have fetched
// First call resolves, second call re-checks cache and finds it populated
const result1 = await promise1;
assert.strictEqual(result1, 'hello');
const result2 = await promise2;
assert.strictEqual(result2, 'hello');
clock.restore();
});
This is the real skill: designing your code so that async boundaries are injectable, then using deferred promises and fake timers to step through every possible interleaving.
Putting it together: a complete test for exponential backoff with concurrency limits
Let me show you a real example that combines fake timers, deferred promises, and injected dependencies. The code is a concurrency-limited retry runner:
type AsyncTask<T> = () => Promise<T>;
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
}
class RetryRunner {
constructor(
private sleep: (ms: number) => Promise<void>,
private now: () => number
) {}
async run<T>(
task: AsyncTask<T>,
config: RetryConfig
): Promise<T> {
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
return await task();
} catch (err) {
if (attempt === config.maxRetries) throw err;
const delay = Math.min(
config.maxDelayMs,
config.baseDelayMs * Math.pow(2, attempt)
);
await this.sleep(delay);
}
}
throw new Error('unreachable');
}
}
Now the test controls every async boundary and verifies the exact timing:
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import sinon from 'sinon';
describe('RetryRunner', () => {
it('retries with exponential backoff at precise intervals', async () => {
const clock = sinon.useFakeTimers();
const sleep = (ms: number) =>
new Promise<void>((r) => setTimeout(r, ms));
const runner = new RetryRunner(sleep, () => Date.now());
const task = mock.fn();
task.mock.mockImplementation(() =>
Promise.reject(new Error('fail'))
);
const promise = runner.run(task, {
maxRetries: 3,
baseDelayMs: 100,
maxDelayMs: 10000,
});
// Attempt 0 failed, waiting 100ms
await clock.tickAsync(100);
assert.strictEqual(task.mock.calls.length, 2);
// Attempt 1 failed, waiting 200ms
await clock.tickAsync(200);
assert.strictEqual(task.mock.calls.length, 3);
// Attempt 2 failed, waiting 400ms
await clock.tickAsync(400);
assert.strictEqual(task.mock.calls.length, 4);
// Attempt 3 failed, maxRetries exhausted
await assert.rejects(promise, /fail/);
clock.restore();
});
});
The test does not sleep for 700ms plus real overhead. It completes in under 10ms and verifies the exact exponential sequence: 100, 200, 400. If someone refactors baseDelayMs to be 50 or changes Math.pow(2, attempt) to a linear increment, the test catches it immediately instead of producing a 500ms timing drift that nobody notices.
A word about real-time tests that must be slow
Not every test can use fake timers. Here are the three exceptions:
-
Integration tests against real services. If you are testing that your code actually talks to a real Postgres or Redis instance, the I/O takes real time. Do not fake timers in these tests. Instead, keep the timing logic isolated in unit tests and verify end-to-end behavior with generous timeouts.
-
Race-condition reproduction in production-like environments. Sometimes the bug only manifests under real network latency. For those cases, write a separate integration test that runs with real delays but includes a retry mechanism (re-run the test up to 10 times) and accepts that it will be slow. The deterministic unit test covers the logic; the slow integration test catches environment-specific issues.
-
Performance regression tests. If you are measuring how long an operation takes under real conditions, fake timers defeat the purpose. Use wall-clock time deliberately, but run these tests in isolation and discard their results if the CI runner is under load.
For everything else (which is about 90% of async tests), use fake timers.
The three rules of async testing
Every async test in your suite should follow these rules:
-
Inject every async boundary. Any dependency that calls
setTimeout,Date.now, or performs a delay must be replaceable in tests. Either pass it as a constructor argument (class) or a function parameter (function). -
Use deferred promises for coordination logic. Any function that uses
Promise.race,Promise.all, or manual promise construction should be testable with controlled, pre-created promises whose resolution order you control. -
Fake timers for all timer-dependent code. If your code uses
setTimeout,setInterval,setImmediate, or checksDate.now()for timing decisions, replace the globals with a fake clock. Never let a test depend on the wall clock for correctness.
When you follow these rules, your async tests become as deterministic as synchronous tests. They pass or fail based on logic, not on the phase of the moon or the current CPU load of the CI runner. The flaky test that has wasted hours of your team’s debugging time disappears entirely.
A note from Yojji
Writing deterministic async tests that catch real bugs without flaking in CI is the kind of engineering discipline that separates a reliable production service from one that pages engineers at 3 AM for phantom failures. It is the same discipline Yojji brings to backend systems they design and deliver for clients.
Yojji is an international custom software development company with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms including AWS, Azure, and Google Cloud, and full-cycle product development from discovery through deployment. Their engineers build production systems like the ones described here, where testing methodology is not an afterthought but a first-class architectural concern.
If you would rather hire a team that ships tested, reliable async code than learn the lessons of flaky CI suites the hard way after a missed deadline, Yojji is worth a conversation.