Root cause #
Debounce, throttle, and polling code all register a callback with setTimeout or setInterval and rely on the event loop to fire it after a delay. Under the real clock, your assertion and that scheduled callback are in a race the runner does not arbitrate: on a fast machine the callback fires first and the test passes; on a busy CI runner the assertion runs before the callback and the test fails. Adding cy.wait(500) or a longer Jest timeout does not remove the race — it just widens the odds while slowing every run. The deterministic fix is to stop the real clock and advance it by hand, so the callback fires at an exact, reproducible moment.
Step-by-step fix #
1. Freeze the clock before triggering scheduled work #
Install fake timers first; any timer the code registers afterward will use the controllable clock.
// Jest: freeze timers before rendering/triggering
beforeEach(() => {
jest.useFakeTimers(); // any setTimeout after this is controllable
});
afterEach(() => {
jest.runOnlyPendingTimers(); // drain queue so nothing leaks
jest.useRealTimers(); // restore — masking risk if omitted: later tests hang
});
// Cypress: freeze Date + timers, optionally pin a start time
cy.clock(new Date('2026-06-20T00:00:00Z').getTime()); // reproducible Date.now()
cy.visit('/search'); // app debounce now reads the frozen clock
2. Advance time by the exact scheduled delay #
Tick forward by the debounce or interval duration to fire the pending callback precisely once.
// Jest: a 300ms debounced search
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'redis' } });
jest.advanceTimersByTime(300); // fires the debounce exactly; CI cost: ~0ms, no real wait
expect(onSearch).toHaveBeenCalledTimes(1);
// Cypress: tick past the same debounce
cy.get('[data-cy=search]').type('redis');
cy.tick(300); // flushes the pending setTimeout deterministically
cy.get('[data-cy=results]').should('contain', 'redis');
3. Drive a polling interval deterministically #
Polling code calls setInterval; advance the clock in interval-sized steps to simulate elapsed cycles.
// Jest: a status poll that runs every 5s, assert after 3 cycles
jest.advanceTimersByTime(15000); // 3 intervals fire; masking risk: too-large jumps batch state
expect(fetchStatus).toHaveBeenCalledTimes(3);
// Cypress: advance the same poll, asserting the UI between cycles
cy.tick(5000); // first poll
cy.get('[data-cy=status]').should('have.text', 'pending');
cy.tick(5000); // second poll — performance: no real 10s elapsed
cy.get('[data-cy=status]').should('have.text', 'ready');
4. Flush timers that schedule async work #
When a timer resolves a promise, advance the clock and then yield to the microtask queue so the .then runs.
// Jest: timer fires, then await flushes the resolved promise's microtasks
jest.advanceTimersByTime(1000);
await Promise.resolve(); // let the timer-resolved promise settle before asserting
expect(screen.getByText('Saved')).toBeInTheDocument();
This timer-plus-promise interaction is the same ordering problem covered in Async State Management in E2E Tests.
Pitfalls #
- Installing the clock after the timer is queued — the real timer already fired or is pending on the real clock. Mitigation: install before the triggering action.
- Never restoring real timers — async libraries and later tests stall. Mitigation:
jest.useRealTimers()inafterEach. - Awaiting a timer-dependent promise without advancing the clock — the test hangs until timeout. Mitigation: advance the clock first, then await.
- Over-advancing an interval — large jumps can batch multiple cycles and hide intermediate states. Mitigation: tick one interval at a time when asserting between cycles.
- Forgetting that
cy.clock()also freezes animations — CSS/rAFwork stalls. Mitigation: disable animations or tick to advance frames.
Reliability targets #
| Metric | Target | How to track |
|---|---|---|
| Debounce test duration | < 20ms, variance near zero |
Jest --verbose timings |
| Timer-related flake rate | < 0.1% over 100 runs |
CI history per spec |
Real cy.wait(ms) calls in suite |
0 for timer logic |
Lint rule / grep audit |
| Restored-timer compliance | 100% of fake-timer specs |
afterEach presence check |
Frequently Asked Questions #
Q: Why does advanceTimersByTime not fire my callback?
A: Either fake timers were installed after the timer was scheduled, or the callback is chained behind a promise that needs a microtask flush. Install the clock before the action, advance the time, then await Promise.resolve() before asserting.
Q: Can I mix fake timers with real network requests in the same test? A: It is risky — many HTTP clients use timers internally for timeouts, which the frozen clock stops. Prefer mocking the network alongside faking timers, or scope fake timers narrowly around the timer-driven assertion.
Q: Is cy.tick() the same as cy.wait()?
A: No. cy.wait(ms) pauses for real wall-clock time and does nothing to the app’s scheduled timers; cy.tick(ms) advances the frozen clock installed by cy.clock(), firing pending timers instantly and deterministically.