Playwright architecture, selector reliability, and advanced interaction patterns.

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.

Two-phase wait through a Suspense boundary The test waits for the skeleton fallback to detach, then for the resolved component to become visible before interacting. skeleton fallback waitFor detached then attached resolved component click Wait for the phase boundary, not a fixed delay Interact only after the resolved node is visible
Suspense is a two-phase transition; synchronizing on each boundary removes the race a fixed timeout only hides.

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.

Matching the wait tool to where readiness lives A matrix mapping each kind of readiness signal to the tool that misfires and the tool that holds under load. Pick the tool that matches the signal Signal lives in Common misuse Reliable tool DOM element state waitForTimeout locator.waitFor() Suspense swap fixed sleep waitFor detached JS store or flag polling a count expect.poll() in-page predicate elementHandle waitForFunction()
Each readiness signal has one tool built to observe it; the misuse column is where fixed delays and stale handles creep in.

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

  1. Prefer locators over handles. Replace every page.$() / elementHandle capture with a locator (ideally getByRole or getByTestId) so the reference re-resolves on each use and never goes stale across reconciliation.
  2. 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.
  3. Choose the right element state. Use state: 'attached' when the element exists but may be hidden, and state: 'visible' when it must be rendered and unobscured before interaction. Picking the wrong state is a common silent flake source.
  4. Use a realistic timeout, never a sleep. Pass an explicit timeout to waitFor and expect for slow fetches instead of waitForTimeout. You widen the retry window without encoding a fixed guess.
  5. 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 or waitForFunction() for an in-page predicate, and target a specific item id rather than a count.
  6. Verify with strict assertions. Close with expect().toBeVisible() or toBeInViewport() 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.

Choosing a wait mechanism by readiness source A decision tree that routes from the readiness question to the matching Playwright wait tool. What signals readiness? DOM element state Suspense phase swap JavaScript state locator auto-wait then waitFor(state) waitFor detached then attached expect.poll or waitForFunction
One question — where the readiness signal lives — routes each wait to the primitive designed to observe that layer.

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.

Back to overview