Flaky Tests
6 min read

Flaky async tests: why fake timers beat sleep() every time

The setTimeout(500) in your test isn't a wait — it's a wager. Here's how to kill the single most common class of flaky async tests for good.

BuildPulse Team

July 29, 2026

Listen

Fix flaky async tests with fake timers | BuildPulse

The bet you keep losing

Somewhere in your test suite there's a line that looks like this:

await new Promise((r) => setTimeout(r, 500));
expect(store.state).toEqual('ready');

That 500 isn't a duration. It's a bet. You're wagering that whatever async work you kicked off finishes in under half a second, on every machine, under every CI load profile, forever. On your laptop it wins. On a cold GitHub Actions runner sharing a box with three other jobs, it loses maybe one time in forty. And one in forty is exactly the failure rate that ruins your week — rare enough that everyone reruns and moves on, common enough that the pipeline is never actually green.

Async timing is, in my experience, the single largest source of CI flakiness in JavaScript and TypeScript suites. Not selectors, not test isolation, not the database. Time. Specifically, tests that reason about time using the real wall clock instead of controlling it.

Let me make the case for fixing this narrowly and completely, rather than sprinkling waitFor everywhere and hoping.

Why real time is the enemy

A test is supposed to be deterministic: same inputs, same result. The moment a test's outcome depends on how fast the machine is, you've smuggled a non-deterministic input into an otherwise pure function. setTimeout, Date.now(), setInterval, debounce/throttle helpers, retry-with-backoff logic, polling loops — every one of these ties your assertion to a clock you don't control.

There are three flavors of this bug, and they all masquerade as each other:

  • The under-wait. You wait 200ms, the work takes 210ms on a slow runner, the assertion fires against stale state.
  • The over-wait that hides a bug. You wait 2 seconds "to be safe." It passes, but now your suite is slow and you've papered over an actual race in the code under test.
  • The interleave. Two async operations resolve in an order that's stable locally but arbitrary under parallel test execution.

All three produce the same symptom in your CI dashboard: a red X that turns green on rerun. Which is why rerun-until-green feels like a fix and is actually just accumulating debt. If you want a longer argument on that, we've made it in why 'just rerun it' is a change-management problem.

Fake timers: control the clock, kill the race

The fix isn't a longer sleep. It's removing time from the equation. Every modern test runner ships a fake-timer implementation for exactly this — Jest and Vitest both do, and they're nearly identical in usage.

Here's a debounced search that's a classic flaky-test factory:

// search.js
export function createSearch(fetchResults, delay = 300) {
  let timer;
  return function search(query, onResult) {
    clearTimeout(timer);
    timer = setTimeout(async () => {
      onResult(await fetchResults(query));
    }, delay);
  };
}

The flaky way to test it:

it('debounces and returns results', async () => {
  const search = createSearch(async (q) => [`hit:${q}`]);
  const onResult = jest.fn();
  search('ab', onResult);
  search('abc', onResult);
  await new Promise((r) => setTimeout(r, 400)); // the bet
  expect(onResult).toHaveBeenCalledWith(['hit:abc']);
});

That 400 is doing two jobs badly: waiting out the 300ms debounce and hoping the fetch promise resolves in the remaining 100ms. Under load, it won't.

Now with fake timers:

import { jest } from '@jest/globals';

beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it('debounces and returns the latest query', async () => {
  const search = createSearch(async (q) => [`hit:${q}`]);
  const onResult = jest.fn();

  search('ab', onResult);
  search('abc', onResult);

  // advance past the debounce and flush the resolved fetch
  await jest.advanceTimersByTimeAsync(300);

  expect(onResult).toHaveBeenCalledTimes(1);
  expect(onResult).toHaveBeenCalledWith(['hit:abc']);
});

No wall-clock dependency. The test advances logical time by exactly 300ms, and advanceTimersByTimeAsync also drains the microtask queue so the awaited fetch resolves before your assertion. Run it on the slowest runner in your fleet or a maxed-out laptop — the result is identical, because there's no race left to lose. That's the whole point: you converted a probabilistic test into a deterministic one.

A few things people get wrong here:

  • Use the *Async variants (advanceTimersByTimeAsync, runAllTimersAsync) when your callbacks are async. The synchronous versions won't flush promises, and you'll be right back to guessing.
  • Fake Date too if your code reads Date.now(). In Jest: jest.useFakeTimers({ doNotFake: [] }) fakes it by default in modern versions; in Vitest use vi.setSystemTime(new Date('2024-01-01')).
  • Restore real timers in afterEach. A leaked fake clock will make an unrelated test hang forever, and that flake is genuinely miserable to trace.

Freeze the clock for date-dependent logic

Fake timers aren't only for setTimeout. Any assertion that touches the current date is a time bomb that goes off at midnight, on the last day of the month, or during a DST transition.

it('marks a token as expired', () => {
  vi.setSystemTime(new Date('2024-03-15T12:00:00Z'));
  const token = { expiresAt: '2024-03-15T11:59:00Z' };
  expect(isExpired(token)).toBe(true);
});

I've watched a payment-retry test pass for six months and then fail every single run for one day because a hardcoded relative date crossed a boundary. The failure had nothing to do with the code and everything to do with the calendar. Freeze the clock and it never happens again.

What fake timers can't fix — and what to do instead

Fake timers solve timer-driven flakiness. They don't solve real async I/O: an actual network call, a database round trip, a file write. For those, the answer isn't a timer at all — it's waiting on the condition, not the clock.

// Bad: wait a fixed amount and pray
await sleep(1000);
expect(screen.getByText('Saved')).toBeInTheDocument();

// Good: wait for the actual condition, with a generous ceiling
await waitFor(() => expect(screen.getByText('Saved')).toBeInTheDocument());

waitFor polls until the assertion passes or a timeout hits. The timeout is a safety net, not the mechanism — on a fast machine it returns in 20ms, on a slow one it takes 400ms, and both are green. Contrast that with sleep(1000), which is slow on the fast machine and still might be too short on the slow one. You get the worst of both.

The rule I hold teams to: no bare setTimeout in a test body, ever. If you're waiting on time, fake it. If you're waiting on a condition, poll for the condition. There is no legitimate third case.

Finding the async flakes you already have

Here's the uncomfortable part. You can adopt every practice above going forward and still have hundreds of these landmines already buried in a suite you didn't write. Grepping for setTimeout finds some. It won't find the ones hiding inside a helper, or the interleave races that have no setTimeout at all.

The only reliable way to find timing flakes is to look at failure patterns over time: which tests fail intermittently, whether the failures correlate with runner load or time of day, and whether they pass on rerun without a code change. A single red build tells you nothing. A test that failed 3% of the time across 900 runs — always with a timeout error, always green on rerun — is a screaming async race.

This is exactly what BuildPulse's flaky test detection is built to surface: it ingests your test results across every run, fingerprints intermittent failures, and ranks them by how much CI pain they're actually causing so you fix the debounce test that's costing you 40 reruns a week before the one that flakes twice a year. We go deeper on the detection method in how we quantify flakiness.

The compliance angle nobody enjoys

If you're in a SOC 2 or ISO 27001 shop, CI is a change-management control. Your test gate is evidence — proof that a change was validated before it shipped. Which makes rerun-until-green a quiet problem. When an engineer reruns a red pipeline until it goes green and merges, the audit trail says the change passed. It doesn't say it passed on the fourth try because an async race finally landed the right way.

That's not a hypothetical an auditor loves. "How do you know your test suite validates changes reliably?" is a fair question, and "we rerun the flaky ones" is not a fair answer. Deterministic tests aren't just faster — they make your CI signal mean what your control documentation claims it means. Quarantining known-flaky tests explicitly, with a tracked remediation path, is a far cleaner story than silent reruns. We covered the control-mapping side in flaky tests and SOC 2 change management.

Where to start Monday

Don't try to boil the suite. Pick the ten tests that fail-and-rerun most often — your detection data will tell you which — and check each for a real-clock dependency. My bet is that the majority contain a setTimeout, a Date.now(), or a fixed sleep. Convert the timer-driven ones to fake timers, convert the I/O-driven ones to condition polling, and delete the bets.

You won't get to zero flaky tests this quarter. But you can get the async ones — the biggest bucket — to genuinely zero, and that's the difference between a CI signal you trust and one you argue with.

Stop guessing which tests you can trust

BuildPulse finds your flaky tests, ranks them by the engineering time they cost, and lets you quarantine the worst in one click. See results on your first build.

Free to start · No credit card required · Setup is a single CI step