A React component debounces its search input with setTimeout. A test calls jest.useFakeTimers(), and now `await userEvent.type(input, 'ada')` never resolves in a React Testing Library test. Why does it hang, and how do you make user-event and RTL's async helpers work under fake timers?
answer
- the tooling schedules timers too
- nobody is turning the clock
- hand user-event a way to advance it
- install fakes before setup, restore after
basics
~20 suser-event schedules delays between keystrokes with the timer API, so once fake timers are installed nothing advances the clock and its promise never settles. Pass an advance function at setup — userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) — so user-event drives the fake clock itself.
solid answer
~40 suser-event v14's `setup()` API inserts a delay between the individual key events it dispatches, and that delay is scheduled through the timer API. Install fake timers and no one advances the clock, so the awaited promise never resolves and the test sits until the runner kills it. The fix is to tell user-event how to advance your fake clock: `const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })` — the equivalent with Vitest is `vi.advanceTimersByTime`. On the RTL side, `waitFor` already detects installed Jest fake timers and advances them itself between retries, so an awaited `findBy*` will still flush a debounce. Install the fake timers before calling `setup()`, restore them afterwards, and consider whether you need them at all — a short injectable debounce with real timers is often the simpler test.
code
javascript · 15 linesimport { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SearchBox from './SearchBox'
beforeEach(() => jest.useFakeTimers())
afterEach(() => jest.useRealTimers())
test('debounces the search request', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
render(<SearchBox />)
await user.type(screen.getByRole('searchbox'), 'ada')
expect(await screen.findByText('3 results')).toBeInTheDocument()
})go deeper
Know that fake timers replace the global timer functions and that anything relying on real time — including test tooling — stops progressing until something advances the clock.
Explain the wiring: user-event delays between key events through timers, so setup() needs an advanceTimers function, installed after the fake timers and torn down afterwards.
Diagnose the hang from the symptom — a runner-level timeout rather than an assertion failure — and argue about whether fake timers belong here at all versus injecting a short interval and asserting the settled outcome.
Own the blast radius. Global fake timers in a shared setup file couple every test in the repo to one scheduler decision; set the policy on where they may be installed and how leakage is prevented across a large suite.
## The collision Fake timers replace `setTimeout`, `setInterval` and friends with controllable stand-ins that only fire when the test advances the clock. That is exactly what you want for testing a debounce: no waiting 300 ms of real time per test. The problem is that fake timers are installed **globally**, so they also capture timers belonging to code that is not the system under test — including the test tooling itself. user-event is one such consumer. Its `setup()` API dispatches a realistic sequence of events per interaction and yields between them, and that yielding is scheduled through the timer API. With real timers it resolves immediately in practice; with fake timers installed and nothing advancing the clock, the promise it returns never settles. The test appears to hang on a line that looks completely innocent: ```js jest.useFakeTimers() const user = userEvent.setup() await user.type(screen.getByRole('searchbox'), 'ada') // never resolves ``` ## The supported fix user-event's `setup()` takes an `advanceTimers` option: a function it calls to move your fake clock forward whenever it needs time to pass. ```js jest.useFakeTimers() const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) ``` With Vitest the same wiring uses `vi.useFakeTimers()` and `vi.advanceTimersByTime`. Two ordering details matter: install the fake timers **before** `setup()`, because setup captures the environment it will drive; and restore real timers afterwards (`jest.useRealTimers()` in an `afterEach`) so the leak does not poison unrelated tests in the same file. ## What happens on the RTL side The async utilities were designed with this collision in mind. `waitFor` detects that Jest fake timers are installed and, instead of waiting on real time between retries, advances the fake clock by its interval each loop. Since `findBy*` and `waitForElementToBeRemoved` are built on `waitFor`, they inherit that behaviour. In practice this means an awaited `findByText('3 results')` after typing will push the fake clock past a 300 ms debounce on its own — you do not usually need to advance manually as well. When you do advance manually, wrap it, because flushing a timer can trigger a React state update that nothing else brackets: ```js act(() => { jest.advanceTimersByTime(300) }) expect(await screen.findByText('3 results')).toBeInTheDocument() ``` ## Diagnosing this class of hang The symptom — a test that times out at the runner level rather than failing an assertion — is the tell. Work through it in order: 1. Are fake timers installed for this test, possibly by a shared setup file rather than the test itself? Global setup makes this genuinely hard to see. 2. Is the awaited call one that needs time to pass (user-event interaction, `waitFor`, a helper of your own that sleeps)? 3. Does anything advance the clock between the scheduling and the await? Most hangs are a missing link in that chain. The related failure mode is the reverse: fake timers installed, no interaction hanging, but the debounce never fires because nothing advanced the clock and the test asserts an empty result list. Same root cause, quieter symptom. ## Should you use fake timers at all? A senior answer includes the option of not reaching for them. Fake timers are global and blunt: they intercept every scheduler in the process, which is why they collide with tooling in the first place. Alternatives that are often better for a component test: - **Inject the delay.** If the debounce interval is a prop or comes from config, the test can pass 10 ms and use real timers. The debounce logic is still exercised; the waiting is not. - **Assert the outcome, not the schedule.** Type, then `await screen.findByText(...)` for the settled result. The test does not care whether 300 ms or 30 elapsed. - **Scope the fakes narrowly.** Install them for the one test that genuinely needs to prove "nothing fires before the interval elapses", and restore immediately. Where fake timers genuinely earn their keep is the negative assertion — proving no request went out until the debounce window closed — because that is the one property real timers can only demonstrate by waiting. ## Restoration hygiene Leaked fake timers are a leading cause of "the test passes alone but fails in the suite". Always pair installation with restoration in `afterEach`, and be suspicious of any global setup file that installs them for every test in the repository; the blast radius is the whole suite, and the failures land in files nobody edited.
- Does RTL's waitFor also hang under fake timers, or does it handle them?It handles them. `waitFor` detects installed Jest fake timers and advances the fake clock by its retry interval instead of waiting on real time, so `findBy*` and `waitForElementToBeRemoved` keep working and will push past a debounce window on their own. The hang comes from user-event's inter-event delays, which need the `advanceTimers` option.
- When would you argue against fake timers in a component test?Whenever the assertion is about the outcome rather than the schedule. If the debounce interval can be injected, pass 10 ms and use real timers — the logic is still exercised without a global scheduler swap. Reserve fake timers for the negative assertion, proving nothing fired before the window elapsed, which real timers can only show by waiting.
- A test passes in isolation but hangs when the whole file runs. How do fake timers explain that?Fake timers are global and survive until restored. A test that installs them without a matching `jest.useRealTimers()` in afterEach leaves every later test running against a clock nobody advances, so the first one that awaits an interaction or a timed helper hangs. Pair installation with restoration, and treat repo-wide setup files that install them as a suite-level risk.
saying these in an interview costs you the question
- Wrapping the interaction in a real setTimeout to "give it time"
- Assuming fake timers only affect the component's own timers
- Calling userEvent.setup() before installing the fake timers
- Never restoring real timers after the test
- Advancing timers outside act and then asserting immediately