skip to content

Async Queries, waitFor & act

You will learn how to test components that update after a promise resolves — findBy*, waitFor, waitForElementToBeRemoved — and what React's act() warning is really telling you. Nearly every RTL interview ends up here, because flaky async tests are the most common real-world pain.

on this pageshow

explore

questions

6

A React component renders a user's name only after a fetch promise resolves. In a React Testing Library test, which query do you use to assert that the name appeared, and what must you do with the value that query returns?

level: juniorimportance: must knowfreq 78%

answer

  1. the query family that returns a promise
  2. retries until the DOM matches
  3. one second is the default budget
  4. forget await and you assert on a Promise

basics

~20 s

Use an async findBy* query, such as screen.findByText('Ada Lovelace'), and await it. It returns a promise that retries the query until the element appears or a default 1000 ms timeout expires; unawaited, you assert on a promise instead of the DOM.

solid answer

~50 s

For anything that appears after a promise resolves, reach for the `findBy*` family — `await screen.findByText('Ada Lovelace')`. A `findBy*` query is the synchronous query plus a retry loop: it re-runs the query whenever the DOM mutates and on a short interval, resolving with the element as soon as it matches, and rejecting with a useful "unable to find" error plus a DOM dump if nothing matches within the default one-second timeout. The critical part is `await`. Without it you hold a pending promise, so `expect(...).toBeInTheDocument()` runs against a Promise object rather than an element, and the state update that eventually resolves lands after your test finished — which is where the "not wrapped in act" warnings and stray unhandled rejections come from. `findAllBy*` behaves the same but resolves with an array as soon as at least one match exists.

code

javascript · 7 lines
javascript
import { render, screen } from '@testing-library/react'
import UserCard from './UserCard'

test('renders the fetched name', async () => {
  render(<UserCard userId="1" />)
  expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument()
})

go deeper

for a junior

Know that findBy* queries return promises and must be awaited, and that they are the right tool whenever content appears after a fetch. Never reach for a manual sleep before a synchronous query.

for a middle

Be ready to explain that findBy* is the synchronous query wrapped in a retry loop driven by a MutationObserver and an interval, with a 1000 ms default deadline, and to show the three-argument form that overrides that timeout.

for a senior

Show the diagnosis: an unawaited async query is why act warnings and unhandled rejections appear in a test that is not the one at fault. Explain how you would find and fix those across an existing suite.

for a principal

Own the standard: lint rules that make unawaited queries a build failure, a default timeout the team does not casually raise, and the position that repeated timeout bumps are a determinism problem disguised as a configuration problem.

## The problem: the DOM at t=0 is not the DOM you are asserting about A component that fetches on mount renders twice, at minimum: once immediately (empty, skeleton, or loading state) and again after the promise resolves and React re-renders. `render()` returns after the first of those. Any assertion written on the next line runs against the *first* DOM, which does not yet contain the fetched name. The test does not need a longer sleep — it needs a query that keeps looking. ## What findBy* actually is Every async query in Testing Library is defined as the synchronous query wrapped in the `waitFor` helper. `findByText(...)` is roughly `waitFor(() => getByText(...))`. That gives it three properties worth stating explicitly: - **It returns a promise.** Nothing in it is synchronous. - **It retries.** The underlying `waitFor` observes the container with a `MutationObserver` and also polls on an interval (50 ms by default), re-running the query each time something could have changed. - **It has a deadline.** The default is 1000 ms, exposed as the `asyncUtilTimeout` configuration value. On timeout it rejects with the same readable error a `getBy*` failure produces, including a printout of the DOM it searched, which is usually enough to see that you were waiting on the wrong text. ```js expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument() ``` Because the query itself throws a descriptive error on failure, the `expect` around it is close to redundant — many teams write `await screen.findByText('Ada Lovelace')` on its own line and treat the absence of a rejection as the assertion. ## Why the await is not optional Dropping the `await` breaks the test in two directions at once. Forward: `expect(somePromise).toBeInTheDocument()` is asserting on a promise object, and depending on your matcher set it either fails with a confusing message or, worse, silently passes a truthiness-shaped check. Backward: the retry loop is still running after the test function returns. When the fetch resolves and React sets state, it does so in a component belonging to a test that has already finished — React emits the "An update to X inside a test was not wrapped in act(...)" warning, the failure surfaces in a *different* test's output, and the suite becomes hard to read. Linting helps here; the `await-async-queries` rule in eslint-plugin-testing-library exists precisely because this mistake is invisible when the test happens to pass. ## Passing options The async queries take the query options as the second argument and the wait options as the third, so a slower path is expressed as: ```js await screen.findByText('Ada Lovelace', {}, { timeout: 3000 }) ``` The empty object in the middle is the query options slot. You can also move the default globally with `configure({ asyncUtilTimeout: 3000 })`, but a global bump slows every failing test in the suite by the same factor, so a local option with a comment is usually the better trade. ## findAllBy* and the "at least one" rule `findAllBy*` resolves with an **array** and is satisfied as soon as one element matches. If the component streams in rows, it can resolve when three of the expected ten have rendered. When the count matters, wait for something that only exists in the final state — a total label, an enabled button — rather than asserting the array length straight out of `findAllBy*`. ## What findBy* does not do It does not wait for the network, a timer, or a promise; it only watches the DOM. If the component never renders the text — wrong prop, mock not wired, error branch taken — you get a one-second delay and then a failure, which is the correct outcome. And it is not a substitute for making the test deterministic: an arbitrary `setTimeout` sleep before a synchronous `getBy*` is the anti-pattern that `findBy*` replaces, because a sleep is simultaneously too long on a fast machine and too short on a loaded CI runner.

  • What actually goes wrong if you forget the await on a findBy query?
    Two things. The assertion runs against a pending promise rather than an element, so it proves nothing. And the retry loop plus the component's state update outlive the test, producing an "update not wrapped in act" warning or an unhandled rejection that surfaces in an unrelated test. The eslint rule `await-async-queries` catches it mechanically.
  • How would you wait longer for one query you know is slow?
    Pass wait options as the third argument: `await screen.findByText('Report ready', {}, { timeout: 5000 })`. Prefer that over `configure({ asyncUtilTimeout })`, which raises the deadline for every test and multiplies the cost of every genuine failure. And first ask why it is slow — an unmocked request or a real timer is a determinism bug, not a timeout bug.
  • If several rows appear one by one, is findAllByRole('row') a safe way to assert there are ten?
    No. `findAllBy*` resolves as soon as at least one element matches, so it can return three rows mid-stream and the length assertion fails intermittently. Wait for a marker that exists only in the settled state — a "10 results" label, an enabled Next button — and then read the rows synchronously.

saying these in an interview costs you the question

  • Adding setTimeout or a sleep before a getBy assertion
  • Calling findByText without awaiting the returned promise
  • Thinking findBy re-renders or re-invokes the component
  • Believing findBy waits on the network rather than the DOM
  • Asserting findAllBy length while items still stream in

context

open as a page

A React Testing Library test logs "An update to Profile inside a test was not wrapped in act(...)". What is that warning actually reporting, and what in the test usually causes it?

level: middleimportance: must knowfreq 68%

basics

~20 s

The warning reports that a component's state updated while React was outside an act() scope — almost always a promise resolving after the test's synchronous body finished. React Testing Library already wraps render, events and its async utilities in act, so the real fix is awaiting the UI change the promise causes.

open as a page

In a React Testing Library test, when do you need waitFor instead of an async findBy* query, and what should never go inside the waitFor callback?

level: middleimportance: must knowfreq 62%

basics

~20 s

Reach for waitFor only when the thing you are waiting on is not "an element appeared" — a mock being called, an attribute flipping, text changing. Its callback re-runs on every DOM mutation and interval, so it must contain assertions only, never clicks, requests or other side effects.

open as a page

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?

level: seniorimportance: should knowfreq 42%

basics

~20 s

user-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.

open as a page

A React Testing Library test clicks Save and then asserts `expect(screen.queryByText('Saving…')).not.toBeInTheDocument()`. Why can that assertion pass even when the spinner logic is broken, and what does waitForElementToBeRemoved give you instead?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The assertion runs before the spinner has even mounted, so it passes by asserting the absence of something that was never present — a vacuous green. waitForElementToBeRemoved throws if the element is missing at call time, forcing the test to prove it appeared and then went away.

open as a page

Component tests written with React Testing Library time out intermittently in CI, and a teammate proposes raising the global asyncUtilTimeout from one second to ten. How would you evaluate that proposal, and what waiting policy would you set for the suite?

level: principalimportance: should knowfreq 24%

basics

~20 s

Timeouts are a symptom, so classify the failures first: genuinely slow but deterministic, nondeterministic, or waiting on something that will never appear. A global bump only helps the first class and makes every real failure ten times slower to report, so keep the default tight and fix determinism instead.

open as a page