Flaky Tests
6 min read

The async race condition is why your tests are flaky

The single biggest source of flaky tests isn't infrastructure — it's async code racing your assertions. Here's how to find and kill it.

BuildPulse Team

July 6, 2026

Async race conditions and flaky tests | BuildPulse

The test that only fails on Tuesdays

You've seen this failure. A test asserts that a button shows a spinner, or that a record landed in the database, or that an event fired. It passes 200 times locally. It passes in the PR. Then three weeks later it goes red in main on an unrelated change, someone hits Re-run jobs, it goes green, and everyone moves on.

Nobody wrote it down. Nobody filed a ticket. The test is now officially flaky, which is engineering-speak for "we've all agreed to stop trusting it."

Here's the thing: that test isn't cursed. In the overwhelming majority of cases I've dug into, the root cause is boring and specific — an async race condition. Your test code and the thing it's testing are running on different timelines, and sometimes the assertion wins the race instead of the code.

This post is about that one root-cause class. Not "what are flaky tests" — you know what those are. This is how async races produce them, how to reproduce them on demand, and how to fix them without reaching for the two worst tools in the box: sleep() and retry until green.

Why async races produce non-determinism

Most modern test suites are full of asynchronous boundaries, whether the author noticed or not:

  • A network call that resolves "eventually"
  • A React state update that flushes on the next tick
  • A message published to a queue and consumed by a worker
  • A debounce or throttle timer
  • A database write that returns before the read replica catches up

When a test asserts right now against a result that arrives soon, the outcome depends entirely on scheduling. On your laptop with a warm cache and one core doing the work, "soon" is 2ms and the test passes. On a shared CI runner under load, "soon" is 400ms and your assertion already fired and failed.

That's the whole mechanism. The test isn't random. It's sampling a distribution, and CI runs the unlucky tail more often because CI is a noisy, resource-starved place. This is why the same test that's rock-solid locally becomes your top source of CI flakiness the moment it runs on shared infrastructure.

The three shapes this bug takes

Let me show you the ones I see constantly.

1. The fixed sleep

it('loads the user profile', async () => {
  render(<Profile userId="42" />);
  await new Promise((r) => setTimeout(r, 100)); // "give it time to load"
  expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
});

The 100 is a guess. It's a bet that the fetch, the state update, and the re-render all finish inside 100ms. On a busy runner, they don't, and you get a red build. Bump it to 500ms and you've slowed the whole suite down to paper over one race — and it'll still fail eventually, just less often. A fixed sleep doesn't fix a race. It negotiates with it.

2. The missing await

it('saves the order', async () => {
  saveOrder(order); // returns a promise nobody awaits
  expect(db.orders).toHaveLength(1);
});

This one is worse because it passes far more often than it fails, so it survives code review for years. The write usually finishes before the assertion by pure luck of the scheduler. Then one day the DB is a hair slower and the length is 0.

3. Shared state between async tests

let cache;
beforeEach(() => { cache = new Cache(); });

it('warms the cache', async () => {
  fireAndForget(() => cache.set('k', 'v')); // not awaited
});

it('reads from a cold cache', () => {
  expect(cache.get('k')).toBeUndefined(); // sometimes the previous test's write bleeds in
});

Here the race crosses a test boundary. An un-awaited operation from test A completes during test B. Test order, parallelism, and runner speed all decide whether you see it. Order-dependent flakiness is its own rabbit hole — we've written about test isolation and order dependence separately — but the async version is the sneakiest because nothing in either test looks wrong.

How to reproduce it on purpose

The fastest way to confirm you've got a race — not, say, an environment issue — is to make the race worse and watch the failure rate climb.

Add artificial latency. If the flaky test talks to a service or a timer, inject delay. If the failure rate jumps, you've found a timing dependency, not a ghost.

Constrain the CPU. Async scheduling is CPU-sensitive. Pin the test process to a single core and run it under load:

taskset -c 0 stress-ng --cpu 4 --timeout 60s &
npx jest flaky.test.js --runInBand --repeat 50

Loop it. Run the single test hundreds of times back to back. A true race will show a failure rate — 3%, 20%, whatever — that a deterministic bug won't:

for i in $(seq 1 200); do npx jest path/to/test.js || echo "FAIL run $i"; done

If you can turn a 1-in-500 failure into a 1-in-5 failure by squeezing resources, congratulations — you've reproduced it, and now you can fix it with a feedback loop measured in seconds instead of weeks.

The actual fixes

Every real fix has the same shape: wait for a condition, not a duration. You replace "wait 100ms and hope" with "wait until the specific observable thing is true, up to a generous timeout."

In the React / Testing Library world:

it('loads the user profile', async () => {
  render(<Profile userId="42" />);
  // resolves as soon as the element appears; fails only if it never does
  expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});

For a general async result, poll the condition:

await waitFor(() => expect(db.orders).toHaveLength(1), { timeout: 5000 });

For the missing-await case, the fix is embarrassingly simple once you see it — await saveOrder(order). The trick is seeing it, which is where a linter earns its keep. Turn on no-floating-promises:

{
  "rules": {
    "@typescript-eslint/no-floating-promises": "error"
  }
}

This rule alone catches a shocking fraction of async flakiness before it ever reaches CI. It's the cheapest test-reliability investment you can make.

And when a genuine time delay is unavoidable — a debounce, a scheduled job — don't sleep the real clock. Control it:

vi.useFakeTimers();
triggerDebouncedSave();
vi.advanceTimersByTime(300); // deterministic, instant
expect(save).toHaveBeenCalledOnce();

Fake timers turn a race into a sequence. No wall-clock, no runner-speed dependency, no flake.

The compliance angle nobody wants to talk about

If you're in a SOC 2 or ISO 27001 shop, your CI gate is a change-management control. Someone signed a document that says code doesn't ship without passing tests. Which makes re-run jobs a quietly radioactive button.

Think about what a re-run actually is. A control failed, and a human overrode it with no recorded reason, no root-cause analysis, and no approval trail — because "it's probably just flaky." That's an unlogged control exception. An auditor who understands CI will ask: how do you distinguish a flaky failure from a real regression that someone re-ran away? If your answer is "gut feel," that's a finding.

An async race makes this materially worse, because those are exactly the tests most likely to occasionally catch a real bug — a genuine ordering violation in production code — and get dismissed as noise. The flakiness gives your team permission to ignore the one signal that mattered.

The fix isn't to ban re-runs. It's to make flakiness a tracked, attributed phenomenon: this test failed on this commit, here's the historical failure rate, here's whether it reproduces on the same SHA, here's who owns it. That's the difference between an audit finding and an audit artifact. Automated flaky test detection gives you the record — quarantine the known-flaky test so it stops blocking merges, keep running it so you keep collecting evidence, and route the failure to an owner instead of a re-run button. The control stays intact and the noise stops holding your pipeline hostage.

What to do Monday

You don't need a big initiative. Start narrow, because async flakiness is narrow:

  1. Turn on no-floating-promises (or your language's equivalent) and fix what it flags. This is a one-afternoon win.
  2. Take your single flakiest test and try to reproduce it with the CPU-squeeze loop above. If it reproduces, it's a race — fix the wait condition.
  3. Ban fixed sleeps in tests via a lint rule. Replace them with condition-based waits or fake timers.
  4. Start recording which tests fail and re-pass on the same commit. That list is your test reliability backlog — and, if you're regulated, your evidence trail.

Flaky tests feel like weather. They're not. They're mostly one bug, wearing a hundred costumes, and the costume is almost always an async race. Once you learn to see it, you can't unsee it — and your CI signal starts meaning something again.