Go to content
Web development

Waiting for the Right Event in Browser Tests

A browser test that saves data has to answer one question: when is the save actually done? The page sends the data to a server, the server stores it, and only then the UI shows the result. If the test checks too early, it sees the old state and fails. Waiting too long wastes time on every run.

The common first attempt is a fixed sleep, a pause of a few seconds before the check. That pause guesses how long the save will take. The guess is wrong on a slow CI runner, a loaded laptop, or a fast machine where the save finishes in a few hundred milliseconds. The reliable alternative is to wait for an observable condition: the UI element that appears when the save completes, the network response that confirms the server stored the data, or the URL that changes after navigation.

Playwright gives you two ways to do this. Web-first assertions retry a check until it passes or the timeout expires, so the test waits exactly as long as the condition takes. Event waits, such as waiting for a specific network response or URL, pause the test until a concrete event arrives. You will see a complete test for an asynchronous save, learn when each wait applies, and see where retrying assertions are not enough.

Why a fixed sleep is a race, not a wait

A fixed sleep, page.waitForTimeout(3000) in Playwright, pauses the test for a set duration and then continues. Playwright’s documentation discourages this method and says tests that wait for time are inherently flaky; use it only for debugging (Playwright wait methods).

The problem is a race between the test and the application. A sleep that is too short makes the test check the page before the save finishes and fail; a sleep that is too long leaves the test idle. Async timing issues like this often cause flaky tests, tests that fail randomly for no apparent reason (flaky test causes). The fixed-period approach fails because the application may need more time than the sleep, or the test wastes time when the application is faster. Polling and callbacks are better solutions (flaky test fixes). A fixed delay may also be too short on a slow CI runner and wasteful on a fast one (waitForResponse guidance).

Playwright’s retrying assertions and event waits are the polling solution: they check the condition repeatedly and finish the moment it becomes true.

What Playwright already waits for: actionability

Before you add any wait, know what Playwright does on its own. When a test calls an action such as click, fill, or press, Playwright first checks that the target element is actionable: attached to the page, visible, stable (not animating), and enabled (actionability checks). Because of this auto-waiting, you don’t need manual waits before acting on an element. If a button appears after a moment, click waits for it.

The limit of auto-waiting matters for async saves. The checks are about the element you act on. They do not extend to the navigation or network request that the action triggers afterwards. Clicking a Save button waits for the button to be clickable, then returns immediately. The save request may still be in flight. That is why the test still needs an explicit wait for the result of the click.

Web-first assertions: wait on the visible result

The default way to wait for a result is a web-first assertion. A web-first assertion retries the check until it passes or the timeout expires, with a default timeout of 5 seconds (Playwright assertions). Instead of sleeping and then checking once, the test checks repeatedly until the condition becomes true.

A locator identifies the element the assertion should find. Locators use the same criteria a user would: role, label, text, or test ID. For example, page.getByText('Saved successfully') finds the element that contains that text.

Here is a complete test for a page that saves a note asynchronously:

import { test, expect } from '@playwright/test';

test('saves a note and confirms the result', async ({ page }) => {
  await page.goto('https://example.com/notes');

  // Auto-waiting handles the fill and the click.
  await page.getByLabel('Title').fill('Grocery list');

  // Set up the network wait before the click so the response cannot slip past.
  const saveResponse = page.waitForResponse(
    (response) =>
      response.url().includes('/api/notes') &&
      response.request().method() === 'POST'
  );

  await page.getByRole('button', { name: 'Save' }).click();
  await saveResponse;

  // The retrying assertion confirms the visible result of the save.
  await expect(page.getByText('Saved successfully')).toBeVisible();
});

The test uses three waits. The fill and click rely on auto-waiting for the elements to be ready. The waitForResponse call must be created before the click, because a response that arrives before the test starts listening is lost. It resolves when the server responds to the POST request to /api/notes. The final assertion retries until the "Saved successfully" message is visible. Each wait ends when its condition is true, so the test runs as fast as the application and does not depend on a guessed duration.

Two habits make assertions more reliable. Prefer expect(locator).toHaveText('Saved') over reading the text with locator.textContent() and then comparing the string, because the separate read loses the auto-retry. Do not add a sleep before an assertion to give the UI time; the assertion already polls, so the sleep only slows the suite. Raising the global assertion timeout to hide a slow wait masks the problem instead of fixing it (assertion best practices). A common mistake is adding page-level waits everywhere when the test only depends on a single element. Wait for the actual UI state, such as expect(page.getByText('Dashboard')).toBeVisible(), instead of waiting for the whole page to settle.

Waiting for navigation and network events

When an action changes the URL, wait for the new URL. page.waitForURL, added in Playwright v1.11, replaces the deprecated expect_navigation, which was racy because it could resolve on an intermediate navigation hop (waitForURL replacement). After a click that navigates, call waitForURL with the expected URL or a pattern, because auto-waiting does not cover the navigation that the click triggers. For element-level state, locator.wait_for, added in v1.16, waits until a single element reaches a specific state such as attached or visible before the test reads from it.

When the action sends a request and the UI does not show a reliable completion signal, wait for the response. page.waitForResponse pauses the test until a network response arrives. The response can match a string, a regular expression, or a predicate function. The predicate can match on URL, status code, request method, or any custom criterion (waitForResponse matching). This synchronizes the test with the actual network event instead of an arbitrary timeout, which may be too short on a slow CI runner and wasteful on a fast one (waitForResponse vs sleep).

One page-level wait to avoid is networkidle. Playwright considers the page idle when no network requests have been made for 500 ms. The state is often discouraged because a single analytics request or a long-polling API can keep the network busy and prevent the wait from resolving (networkidle caveats). A page that is already rendered can still be waiting on background traffic, so networkidle tells you nothing about the save you care about.

Where retrying assertions are not enough

A retrying UI assertion confirms what the page displays, but the display can be ahead of the data. Two common cases break the assumption that a visible message means the work is done.

The first is optimistic UI. Some pages show "Saved" as soon as the request is sent, before the server confirms. The assertion passes while the save is still in flight, and a later step that depends on the saved data can fail. The second is a button that re-enables when the request starts rather than when it finishes. The click succeeds, the button becomes clickable again, and an assertion on the button state passes early.

In both cases, the completion signal is the network response, not the UI. Use page.waitForResponse with a predicate that matches the save endpoint, and only then assert on the visible result. The test in the previous section does exactly this: it waits for the POST response to /api/notes before checking the "Saved successfully" message.

The decision rule is to wait for the actual application state the test depends on: a visible UI element, a resolved API response, or a completed navigation. When the UI state is trustworthy, the retrying assertion is the simplest wait. If the UI can show a state before the data is committed, wait for the response instead. For dynamically loaded content, wait for the element or the response that delivers it (dynamic content handling). Keep assertions on user-visible behavior rather than implementation details such as CSS class names, which break on cosmetic refactors (Playwright best practices).

Key Takeaways

  • Replace fixed sleeps with waits on observable state: a retrying UI assertion, a network response, or a completed navigation. A sleep is a guess; an observable wait ends exactly when the condition is true.
  • Use web-first assertions like expect(locator).toBeVisible() as the default check: they retry until the condition passes or the 5-second default timeout expires.
  • Set up page.waitForResponse before the click that triggers the request: match on URL, method, or status so the test synchronizes with the actual network event.
  • Reserve page.waitForURL for navigations and locator.wait_for for reading an element’s state: avoid networkidle and waitForTimeout, which hang or flake on real-world traffic.
  • When the UI can show a state before the data is committed, wait for the application-specific completion signal: the API response, not the visible message.
More in Web development Back to top ↑