Replace Fixed Test Delays with Vitest expect.poll
Fixed-delay sleeps make async tests either flaky or slow. Vitest's expect.poll retries standard matchers until a condition passes, giving you a deterministic middle ground—if you tune it correctly.
04 Apr 2026, 23:05 UTC

If you have ever written await new Promise(r => setTimeout(r, 100)) inside a test, you already know the cost. Wait too little and the suite fails randomly on CI; wait too long and every run drags. The problem is not the assertion—it is that you are guessing how long an asynchronous operation will take.
Vitest's expect.poll removes the guesswork. Instead of sleeping for a fixed duration, it repeatedly runs a callback and retries a standard matcher until the assertion passes or a timeout is reached. The useful takeaway: for code that eventually reaches a known state, polling turns a race condition into a bounded wait without requiring new assertion syntax.
Why fixed delays fail
A fixed delay assumes the system under test is slower than your patience but faster than your timeout. In practice, CI runners, garbage collection, and network jitter make that assumption fragile. A 50 ms sleep might pass locally and fail on a loaded build agent. Increasing the sleep to 500 ms makes the suite slower without making it more correct.
How expect.poll works
expect.poll accepts a function that returns the value you want to inspect. Vitest invokes that function on an interval and applies the matcher you chain onto it. When the matcher passes, the promise resolves immediately. If the timeout elapses first, the test fails.
Because it wraps ordinary matchers, the learning curve is minimal:
await expect.poll(() => state.value).toBe(42);
You can configure the behavior with an options object. The exact defaults vary by Vitest version, so treat these as illustrative and verify them against your installed release:
await expect.poll(() => cache.get(key), {
interval: 25,
timeout: 2000,
}).toBe('expected-value');
Where to run it: inside a Vitest test file (e.g. *.test.ts). No special permissions are required beyond your normal test runner setup.
Worked example: an async cache write
Imagine an in-memory cache that persists writes asynchronously. A naive test sleeps for an arbitrary period before asserting:
class Cache {
private store = new Map<string, string>();
async set(key: string, value: string) {
// Simulate async persistence
await new Promise(resolve => setTimeout(resolve, 20));
this.store.set(key, value);
}
get(key: string) {
return this.store.get(key);
}
}
test('cache reflects async write (brittle)', async () => {
const cache = new Cache();
cache.set('user:1', 'alice');
await new Promise(r => setTimeout(r, 50));
expect(cache.get('user:1')).toBe('alice');
});
If the simulated write takes longer than 50 ms under load, the test fails even though the code is correct. Replacing the sleep with expect.poll makes the test wait only as long as necessary:
test('cache reflects async write (polled)', async () => {
const cache = new Cache();
cache.set('user:1', 'alice');
await expect.poll(() => cache.get('user:1')).toBe('alice');
});
The test now passes as soon as the value appears and fails only if the write never completes within the timeout.
The trade-offs
Polling is not free. The most important limitation is that it masks latency. If a cache write that used to take 20 ms suddenly takes 2 seconds, a polled test will still pass—it just takes longer. A strict event-based assertion would fail immediately and flag the regression.
Polling also increases worst-case duration. When a polled assertion fails, it waits for the full timeout before throwing. In a large suite where many tests fail, those timeouts add up and slow down CI feedback.
| Approach | Best for | Risk |
|---|---|---|
Fixed setTimeout | Nothing, ideally | Flaky or wastefully slow |
expect.poll | Eventual state without an event signal | Hides latency; slow on failure |
| Await an event/promise | Code that emits completion events | Requires instrumentation |
When to prefer event-driven waiting
If your code emits a completion event, resolves a promise, or invokes a callback when work is done, awaiting that signal is faster and more deterministic than polling. Use expect.poll when you are waiting for a side effect that has no explicit completion signal—such as a DOM update, a log line, or a cache entry populated by a background worker.
Idempotency matters
The callback you pass to expect.poll runs multiple times. If it triggers a side effect—incrementing a counter, appending to a log, or consuming a queue item—those effects repeat. Keep the callback read-only.
Practical checklist
- Search your test suite for
setTimeoutsleeps used solely to wait for async work. - Replace them with
expect.pollwhen you are asserting on eventual state. - Set an explicit
timeoutthat reflects your performance budget, not just "enough to pass." - Prefer awaiting events or promises when the code under test provides them.
- Verify the defaults for your Vitest version in the official documentation before relying on them.
To check the result, run the affected test file with Vitest. If the polled test passes reliably under load and fails quickly when you deliberately break the async operation, the migration worked. If it hangs until the timeout, your interval or timeout is likely misconfigured for the workload.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.