Waiting Strategies for Dynamic React Components
React rarely hands you a stable DOM. Fiber reconciliation detaches and re-attaches nodes during state transitions, Suspense swaps a skeleton for resolved markup, and useEffect fetches data after first paint. A test that grabs an elementHandle or sleeps for a fixed interval is racing all of this, which is why the same spec passes locally and fails under CI load. The reliable approach pairs Playwright's auto-waiting locators with state-aware waitFor(), and reaches for waitForFunction() or expect.poll() only when the condition lives in JavaScript state rather than the DOM. This page traces the root cause and gives tested patterns for Suspense, virtualized lists, and hydration gates. It extends the Handling Dynamic Content guide within Reliable Selector Strategies for Playwright.
Root cause: reconciliation detaches the node you captured
React's reconciler diffs the virtual tree and applies the minimum DOM mutations, which frequently means detaching a subtree and mounting a fresh one during a state change. Any reference you captured eagerly — a page.$() handle or an elementHandle — points at the old node and goes stale the instant React replaces it, surfacing as Element is not attached to the DOM. Fixed sleeps fail for the opposite reason: they encode a guess about timing that holds on a fast laptop and breaks on a loaded CI runner. Concurrent features widen the gap further — with startTransition and streaming server rendering, React may commit an interim tree, then reconcile again microseconds later, so even a node that existed a frame ago can be replaced before your next line runs.
Playwright's locator API is built for exactly this. A locator is a lazy description, not a captured node; it re-resolves and retries on every action and assertion, so it absorbs most reconciliation gaps automatically. The remaining cases are multi-phase transitions the engine cannot infer — a Suspense fallback that must detach before the real component attaches, or readiness that lives in a store rather than the DOM. For those you add explicit, state-aware waits: waitFor({ state }) on a locator for DOM phases, and waitForFunction() or expect.poll() for JavaScript state. Throughout, prefer getByRole and data-testid over CSS so the locator survives React upgrades, exactly as the Optimizing XPath for SPA Navigation guide argues for route transitions.
The practical decision is always the same: identify where the readiness signal lives, then pick the one tool built to observe that layer. Choosing by habit — reaching for a sleep, or polling a rendered count when the truth sits in a store — is what turns a real condition into an intermittent one. The matrix below pairs each kind of signal with the tool that quietly misfires against it and the tool that actually holds under load.
Minimal reproducible example
This test synchronizes through a Suspense boundary: it confirms the skeleton, waits for the resolved region to attach and become visible, then interacts.
import { test, expect } from '@playwright/test';
test('waits through a Suspense boundary before interacting', async ({ page }) => {
await page.goto('/dashboard');
// Phase 1: the Suspense fallback skeleton is on screen first.
const skeleton = page.getByTestId('skeleton-loader');
await expect(skeleton).toBeVisible();
// Phase 2: wait for the skeleton to detach so we know React swapped it.
await skeleton.waitFor({ state: 'detached' });
// Resolve the real component by role; the locator re-evaluates lazily,
// so reconciliation cannot leave us holding a stale node.
const widget = page.getByRole('region', { name: 'Analytics Widget' });
// Confirm it is attached and visible before any action.
await widget.waitFor({ state: 'attached', timeout: 10000 });
await expect(widget).toBeVisible();
// Safe interaction: the inner button query also auto-waits.
await widget.getByRole('button', { name: 'Refresh' }).click();
});
Read this test as a sequence of proofs rather than a sequence of delays. Asserting the skeleton first establishes that the loading phase actually rendered, so a build that skipped straight to the resolved state — or failed to mount the fallback at all — fails loudly instead of passing by accident. The detached wait then proves React completed the swap, and resolving the region by its accessible role means the query re-runs against whatever the reconciler last committed, not against a node captured moments earlier. Every line answers a question about application state, and because each step blocks until its condition is genuinely true, the test carries no timing assumptions from the machine it was written on into the machine it runs on. That is the property a fixed sleep can never provide, and it is why the same spec survives the jump from a developer laptop to a saturated CI shard.
Step-by-step fix
- Prefer locators over handles. Replace every
page.$()/elementHandlecapture with alocator(ideallygetByRoleorgetByTestId) so the reference re-resolves on each use and never goes stale across reconciliation. - Synchronize the fallback phase. Assert the loading skeleton is visible, then
waitFor({ state: 'detached' })on it, so the test proves React tore down the fallback before looking for the resolved component. - Choose the right element state. Use
state: 'attached'when the element exists but may be hidden, andstate: 'visible'when it must be rendered and unobscured before interaction. Picking the wrong state is a common silent flake source. - Use a realistic timeout, never a sleep. Pass an explicit
timeouttowaitForandexpectfor slow fetches instead ofwaitForTimeout. You widen the retry window without encoding a fixed guess. - Poll JavaScript state with the right tool. When readiness lives in a store or window flag rather than the DOM, use
expect.poll()for a Playwright value orwaitForFunction()for an in-page predicate, and target a specific item id rather than a count. - Verify with strict assertions. Close with
expect().toBeVisible()ortoBeInViewport()on the exact element you will act on, so strict locator mode catches ambiguity before it becomes flakiness.
When you are unsure which mechanism a given step needs, route the question through the readiness source rather than the symptom. The tree below starts from a single question — where does the signal you are waiting on actually live — and lands on the matching tool, so you never fall back on a sleep because the right primitive was unclear.
Troubleshooting variants
A virtualized list item never appears
Virtualized feeds mount rows only as they scroll into view, so a query for a far-down item times out. The window of live DOM nodes is tiny by design — an item that is logically the forty-second entry may have no element at all until you scroll its offset into range, and even then the library may recycle the same node for different data. Scroll the container, then wait for that specific item by id rather than a row count, because a count assertion can pass against recycled placeholders while the row you actually want is still off-screen.
import { test, expect } from '@playwright/test';
test('waits for a virtualized list item after scroll', async ({ page }) => {
await page.goto('/feed');
// Drive the list to the bottom to trigger windowed rendering.
await page.getByTestId('virtual-list').evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
// Poll for the exact item to exist, not a fragile nth-position.
await page.waitForFunction(
() => !!document.querySelector('[data-item-id="post-42"]'),
{ timeout: 15000 },
);
await expect(page.getByTestId('post-42')).toBeInViewport();
});
The component depends on hydration finishing
When interactivity requires hydration but no DOM change signals it, poll the app's readiness flag with expect.poll() before asserting on UI. Server-rendered markup is present and visible long before its event handlers attach, so a click issued against a hydrating button is silently dropped and the test fails one assertion later with no obvious cause. A window flag the app sets on its hydration callback gives Playwright a truthful signal to gate on; if the app exposes no such flag, the resilient fallback is to wait for a behaviour that only works post-hydration — a controlled input echoing a keystroke, say — rather than for the element's mere presence.
import { test, expect } from '@playwright/test';
test('waits for the React app to hydrate', async ({ page }) => {
await page.goto('/app');
// Poll a window flag the app sets once hydration completes.
await expect
.poll(() => page.evaluate(() => (window as { __APP_READY__?: boolean }).__APP_READY__), {
timeout: 10000,
intervals: [200, 500, 1000],
})
.toBe(true);
await expect(page.getByRole('main')).toBeVisible();
});
useEffect fetches make the test flaky
Unawaited fetches in useEffect resolve at unpredictable times. Mock the slow endpoint with page.route() during local debugging to get deterministic timing, and attach a DOM snapshot with test.info().attach() to diagnose the exact failure phase. The failure signature is telling: if the assertion sometimes passes and sometimes reports an empty region, the fetch is still in flight when the query runs, so anchor on the rendered result — a row, a heading, a value the response produces — rather than on the container that exists before the data arrives. Where the fetch feeds a client store instead of the DOM directly, poll that store with expect.poll() so the wait tracks the same source of truth the component reads. This complements the synchronization patterns in the wider Handling Dynamic Content guide.
Verification
Prove the waits hold three ways. Run npx playwright test --repeat-each=10 on the spec — phase-aware waits stay green where a fixed sleep flaked. Enable trace: 'on-first-retry', force a failure, and step through the snapshots to confirm the skeleton detached before the resolved component query ran. Finally, throttle the network in DevTools or add latency via page.route() and rerun: explicit timeouts should absorb the delay rather than surfacing a stale-element error. Treat the repeat-each run as the real gate — a single green pass proves nothing about a race, whereas ten consecutive passes under artificial latency demonstrate the wait is synchronizing on the condition itself rather than getting lucky with timing. Capture the passing trace as the baseline so a future regression that reintroduces a sleep is visible as a change in the recorded wait steps.
Frequently Asked Questions
When do I need waitForFunction instead of a locator wait?
Use a locator's waitFor() whenever the condition is a DOM element state. Reach for waitForFunction() (or expect.poll()) only when readiness lives in JavaScript — a store value, a window flag, or a computed property the DOM does not reflect.
Why is waitForTimeout discouraged?
A fixed sleep encodes a timing guess that holds on a fast machine and breaks under CI load, so it hides races rather than resolving them. State-aware waits retry until the real condition is met, which is both faster on average and reliable.
How do I wait for a Suspense fallback to disappear?
Treat it as two phases: assert the skeleton is visible, then waitFor({ state: 'detached' }) on it, and only then resolve and assert the real component. This proves React completed the swap instead of guessing a delay.