Testing async code without flakes
A test that passes locally and fails randomly in CI is almost always a missing await. Learn the pattern, fake timers, and why Express 5 changed the game.

A flaky test is one that passes most of the time and fails for no reason you can reproduce on demand. Nine times out of ten, "flaky" means "async," and "async" means someone forgot an await. The test finished before the code it was testing actually did, and it happened to get lucky most of the time. This lesson is about not needing luck.
The bug that passes anyway
Here's a function that "validates" an email by checking it against a (pretend) service, with a delay to stand in for a real network call:
result prints undefined, not true, because the .then() callback hasn't run yet when console.log fires. The promise resolves 50 milliseconds later, and by then nobody's looking. A test written this way, checking result synchronously right after kicking off the promise, would pass on an assertion that never actually ran against real data, because undefined === undefined if the test also forgot to assert anything meaningful. That's the shape of a flaky async test: not "sometimes wrong," but "checks nothing, and gets away with it most of the time."
The fix: await it
result is true, correctly, because the code now waits for the promise to settle before reading its value. In real Vitest, the equivalent test looks like this:
it("resolves true for a taken email", async () => {
const result = await checkEmailExists("taken@example.com");
expect(result).toBe(true);
});The it callback is marked async, and the test runner waits for the returned promise to settle before deciding pass or fail. Forget the async keyword or the await, and Vitest has no way to know your assertion hasn't run yet, so a test that should fail can report green.
Testing rejections
expect(promise).rejects.toThrow() (with await in front, or returned) is the matcher for a promise that's supposed to fail. Don't wrap it in a manual try/catch unless you need to assert on more than the thrown error itself.
Don't make real tests wait on real timers
A test that calls setTimeout(fn, 60000) and waits for it to actually fire will genuinely sit there for a minute. Vitest's fake timers fix that:
import { describe, it, expect, vi } from "vitest";
it("retries after the backoff delay", () => {
vi.useFakeTimers();
const onRetry = vi.fn();
scheduleRetry(onRetry, 60000);
vi.advanceTimersByTime(60000);
expect(onRetry).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});vi.advanceTimersByTime(60000) fast-forwards the clock without the test actually waiting a minute, so a suite testing retry logic, rate limits, or session expiry runs in milliseconds instead of becoming the slowest thing in your CI pipeline. Always pair vi.useFakeTimers() with vi.useRealTimers() at the end, the same way a spy needs mockRestore(), or the fake clock leaks into whatever test runs next.
Why Express 5 made this easier on the server
If you've read the error-handling lesson in the Backend & APIs series, you've seen this comment in Linkstash's app.ts:
/** Express 5 awaits the promise returned by an async handler and routes any
* rejection to the error middleware automatically. Express 4 did not, which
* is why so many older tutorials wrap every handler in try/catch. */That matters here because it's the server-side version of the exact bug above: a route handler that returns a promise nobody's watching. Express 4 would let a rejected promise vanish into an unhandled rejection, with the request just hanging. Express 5 awaits it for you and forwards any error, which is also why the /boom route we'll test in the next lesson reliably produces a clean 500 instead of crashing the process.
Quick check
A test calls an async function but forgets `await` in front of it. What's the most likely outcome?
Previous: mocks, stubs and fakes. Next: integration-testing Linkstash, where async testing meets a real HTTP request.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


