Handling Time in Tests: Reliable Patterns for Async and Delays
Time is the silent killer of test reliability. A test that passes locally fails in CI. A suite that ran in seconds now takes minutes. A component that works perfectly shows phantom failures because timers and promises don’t cooperate.
This article covers modern async test timing patterns and fake timers best practices for Jest, Vitest, React Testing Library, Playwright, and Cypress—focusing on what actually works in 2025.
Key Takeaways
- Use async timer APIs (
advanceTimersByTimeAsync) instead of synchronous variants to prevent promise/timer deadlocks - Configure auto-advancing timers when combining fake timers with Testing Library’s async utilities
- Always clean up fake timers in
afterEachto prevent test pollution and cascading failures - In E2E tests, wait for specific conditions rather than using arbitrary delays like
waitForTimeoutorcy.wait(ms)
Why Time Breaks Tests
Tests fail around time for three reasons: real delays slow execution, timing variations cause flakiness, and fake timers conflict with promises.
The JavaScript event loop processes macrotasks (setTimeout, setInterval) and microtasks (Promise callbacks) in a specific order. Fake timers intercept macrotasks but don’t automatically handle microtasks. This mismatch causes most timer-related test failures.
Jest Async Timers: The Modern Approach
Jest’s current fake timer implementation uses @sinonjs/fake-timers under the hood. The key to reliable async testing is understanding which APIs to use together.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('debounced search triggers after delay', async () => {
render(<SearchInput />);
await userEvent.type(screen.getByRole('textbox'), 'query');
await jest.advanceTimersByTimeAsync(300);
expect(mockSearch).toHaveBeenCalledWith('query');
});
The critical detail: use advanceTimersByTimeAsync instead of advanceTimersByTime. The async variant flushes microtasks between timer executions, preventing the promise/timer deadlock that causes hanging tests.
For animations, Jest provides jest.advanceTimersToNextFrame() to step through requestAnimationFrame callbacks without waiting for actual frame timing.
Vitest Fake Timers: Sinon Integration
Vitest shares the same @sinonjs/fake-timers foundation, making Vitest fake timers behave similarly to Jest with minor API differences.
import { vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('toast disappears after timeout', async () => {
render(<Toast message="Saved" />);
await vi.advanceTimersByTimeAsync(3000);
expect(screen.queryByText('Saved')).not.toBeInTheDocument();
});
Both frameworks support auto-advancing timers, which advances time automatically when the event loop would otherwise idle. This eliminates manual tick management for most tests.
Discover how at OpenReplay.com.
React Testing Library Coordination
The interaction between fake timers and Testing Library’s waitFor causes frequent confusion. When fake timers are active, waitFor polls but time doesn’t advance—creating infinite loops.
The solution: configure fake timers to advance automatically during async waits.
jest.useFakeTimers({ advanceTimers: true });
This tells Jest to advance timers when promises are pending, allowing waitFor and findBy queries to work naturally with fake timers.
For user-event, ensure you’re using the current API. The library handles its own timing internally and coordinates with fake timers when configured correctly.
E2E Time Control: Playwright and Cypress
Playwright’s page.clock API provides precise time control in browser contexts:
await page.clock.install({ time: new Date('2025-01-15') });
await page.clock.fastForward(5000);
Cypress offers cy.clock() and cy.tick() for similar control. Both approaches are preferable to waitForTimeout or cy.wait(ms), which introduce real delays and flakiness.
Common Pitfalls That Cause Flakiness
Timer leaks between tests: Always restore real timers in afterEach. Leaked fake timers cause cascading failures in subsequent tests.
Third-party library conflicts: Libraries like animation frameworks or polling utilities schedule their own timers. These may not respect your fake timer installation, causing unexpected behavior.
Microtask/macrotask ordering: Code that mixes setTimeout with Promise.resolve() behaves differently under fake timers. Use async timer APIs consistently.
Temporal API considerations: Newer codebases using Temporal instead of Date need separate mocking strategies. Abstract your time source to make tests framework-agnostic.
Conclusion
Prefer async timer APIs (advanceTimersByTimeAsync) over synchronous variants. Configure auto-advancing when using Testing Library async utilities. Avoid arbitrary sleeps and waitForTimeout in E2E tests—wait for specific conditions instead. Always clean up fake timers to prevent test pollution.
The goal is tests that run fast, pass consistently, and don’t encode timing implementation details. Auto-advancing fake timers get you most of the way there. For everything else, explicit async coordination beats arbitrary delays every time.
FAQs
Tests hang when fake timers block macrotasks while promises wait for microtasks to resolve. Use async timer methods like advanceTimersByTimeAsync instead of advanceTimersByTime. The async variants flush microtasks between timer executions, breaking the deadlock between promises and timers.
Configure Jest with the advanceTimers option enabled by calling jest.useFakeTimers({ advanceTimers: true }). This tells Jest to automatically advance time when promises are pending, allowing waitFor and findBy queries to poll correctly without creating infinite loops.
Use fake timers via Playwright's page.clock or Cypress's cy.clock and cy.tick. Real delays with waitForTimeout or cy.wait introduce flakiness because execution speed varies across environments. Instead, wait for specific UI conditions or use controlled time advancement.
CI environments have variable CPU and memory resources, causing timing-dependent tests to behave inconsistently. Replace real delays with fake timers, use async assertions that wait for conditions rather than fixed durations, and ensure proper timer cleanup between tests to prevent pollution.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.