↓ Skip to main content

Playwright UI Testing Testing Best Practices

Playwright "Test timeout of 30000ms exceeded.": Find the Wait That Never Finished

What Playwright's "Test timeout of 30000ms exceeded." actually measures, the usual causes in the order I'd check them, how to find the step that hung, and when raising the timeout is the wrong fix. With a real failure from my own suite: five tests and one renamed test ID.

Playwright "Test timeout of 30000ms exceeded.": Find the Wait That Never Finished

Test timeout of 30000ms exceeded. means a Playwright test ran past its 30-second budget. Thirty seconds is Playwright Test’s default, and the budget covers the test body, its beforeEach hooks and its fixture setup together. The message names the test rather than the step because clicks, fills and navigations have no timeout of their own by default. They wait as long as it takes, and the test timeout is simply the first clock to run out.

So the error is rarely about speed. Usually one step was waiting for something that was never going to happen. Here are the causes in the order I’d check them, with the fix for each:

  1. A locator matches nothing, so a click() or fill() waits until the test is out of time. A renamed test ID, changed text or a dialog in the way is the usual reason. Fix the locator.
  2. A navigation or waitForURL() is waiting for a page that never comes, because a redirect, a login screen or a first-run screen took the app somewhere else. Fix what you’re waiting for.
  3. Setup really is slow: a dev server compiling on first request, a backend waking from scale-to-zero, a full UI login in every beforeEach. Fix the environment, or do the setup once.
  4. The test is simply long. Then, and only then, give that test more time with test.slow() or test.setTimeout().

Whichever it is, the first job is finding the step that was still running when the time ran out. The second is setting actionTimeout, so the next failure names the step for you.

What the 30 seconds covers
#

The test timeout is one budget, shared by fixture setup, beforeEach hooks and the test function. When the test finishes, afterEach hooks and fixture teardown get a second budget of the same size. beforeAll and afterAll get the same value as well, but they don’t share it with any test, and they fail with their own message.

Inside that budget, only assertions have a clock of their own. await expect(locator).toBeVisible() gives up after 5 seconds by default and tells you exactly what it was looking for. Actions and navigations don’t: actionTimeout and navigationTimeout default to no limit at all. That is the whole reason this error is so vague. A click on an element that will never exist doesn’t fail. It waits, politely, until something else ends the test.

Here is every timeout that can end a run, and what each one prints. The messages are copied from Playwright 1.58 and 1.63, the oldest and newest versions in my projects.

TimeoutDefaultWhere to set itWhat it prints
Test (body, beforeEach, fixture setup)30,000 mstimeout in the config, --timeout, test.setTimeout(), test.slow()Test timeout of 30000ms exceeded.
beforeAll / afterAllsame as the test timeouttest.setTimeout() inside the hook"beforeAll" hook timeout of 30000ms exceeded.
Assertion (expect)5,000 msexpect: { timeout } in the config, { timeout } on the assertionexpect(locator).toBeVisible() failed … Timeout: 5000ms
Action (click, fill)noneuse: { actionTimeout }, { timeout } on the actionTimeoutError: locator.click: Timeout 10000ms exceeded.
Navigation (goto, waitForURL)noneuse: { navigationTimeout }, { timeout } on the callTimeoutError: page.waitForURL: Timeout 15000ms exceeded.
Whole runnoneglobalTimeout in the configTimed out waiting 3600s for the test suite to run
webServer start-up60,000 mswebServer: { timeout }Timed out waiting 60000ms from config.webServer.

The action, navigation and whole-run rows have no default to print, so those numbers are whatever you set.

Five tests and a renamed test ID
#

In February, five tests in the suite for my estimating app failed in one run, all the same way. The suite drives the web version of the app, served by its dev server, on one worker, against a freshly seeded local backend. Here’s the first of the five, trimmed:

  1) [default] › tests/templates.spec.ts:50:7 › Templates › should edit a template ───

    Test timeout of 30000ms exceeded.

    Error: locator.click: Target page, context or browser has been closed
    Call log:
      - waiting for locator('[data-testid="template-item"]').filter({ hasText: 'E2E Edit Template' }).locator('[data-testid="edit-template"]')


      57 |       .locator('[data-testid="template-item"]')
      58 |       .filter({ hasText: "E2E Edit Template" });
    > 59 |     await item.locator('[data-testid="edit-template"]').click();
         |                                                         ^
      60 |     await page.waitForTimeout(500);

The Target page, context or browser has been closed line reads like the browser falling over. It isn’t. It’s what an action says when the page is closed underneath it: the 30 seconds ran out, Playwright tore the test down, and the click that was still waiting reported the page it had lost. With the built-in page fixture, that line would have repeated the timeout instead, as Error: locator.click: Test timeout of 30000ms exceeded. These tests open their own browser context in beforeAll, so the click got the generic version.

The useful line is the call log. The click was waiting for [data-testid="template-item"]. Ten hours earlier I had changed the row component’s test ID from template-item to template-item-${name}, so the end-to-end tests on the native app could pick out one row by name. An attribute selector with = matches the whole value, so the web tests were now asking for an element that existed nowhere on the page, and with no action timeout that click was always going to wait out the test. Five tests at 30 seconds each is two and a half minutes, in a run that took 3.8.

What I added first was a retry. Within half an hour the config had retries: process.env.CI ? 2 : 1, with a comment that still says it’s there “for flaky browser crashes”. On the next run, four of the same five failed again, retry included, and the run took 6.4 minutes instead of 3.8. These describe blocks run in serial mode, where a failure skips the rest of the group and a retry starts the group again from the top, so every bad click now waited out its 30 seconds twice. A retry can’t rescue a test that fails the same way every time. It just bills you twice.

The fix was a prefix match:

const item = page
  .locator('[data-testid^="template-item-"]')
  .filter({ hasText: "E2E Edit Template" });
await item.locator('[data-testid="edit-template"]').click();

The wider lesson is that a test ID is a contract, and I had two test suites depending on this one without either knowing about the other. Playwright’s best-practice guide recommends user-facing locators such as getByRole() over CSS selectors like mine, so that tests survive DOM changes a user would never notice.

Find the step that hung
#

Read the call log first. It belongs to the step that was still running when the time ran out, and a waiting for locator(...) line with nothing after it means the locator never matched anything.

If that isn’t enough, open the trace. My two app suites, one for the estimating app and one for a marketplace and community app I build, both use trace: "on-first-retry", which only records on a retry, so it needs retries set to at least 1. Without retries, use "retain-on-failure"; for a one-off run, pass --trace on. Then open the trace directly, or from the HTML report:

npx playwright show-trace test-results/<test-folder>/trace.zip
npx playwright show-report

The action that never finished is the one carrying the error, near the end of the list. Select it and look at the snapshot: that’s the page as it was while Playwright waited, and you can inspect it for the element you expected. In a case like mine, the row is sitting right there with an ID the test isn’t asking for.

Recent versions also write an error-context.md beside the failure screenshot, with a text snapshot of the page at the moment it failed. For a missing element, that’s often enough on its own.

One trap: --debug opens the Inspector and also sets --timeout=0, along with --max-failures=1, --headed and --workers=1. A test that passes under --debug hasn’t told you anything about the 30 seconds.

Give every action a ceiling
#

None of my configs set an action timeout, which is how one missing element got to spend 30 seconds, five times over. Two lines in use change that:

import { defineConfig } from "@playwright/test";

export default defineConfig({
  timeout: 30_000,
  expect: { timeout: 5_000 },
  use: {
    actionTimeout: 10_000,
    navigationTimeout: 15_000,
    trace: "retain-on-failure",
  },
});

The same broken locator then fails like this, while the test still has time left:

    TimeoutError: locator.click: Timeout 10000ms exceeded.
    Call log:
      - waiting for locator('[data-testid="template-item"]').filter({ hasText: 'E2E Edit Template' }).locator('[data-testid="edit-template"]')

That’s the step, the action and how long it waited. I’d keep both numbers well under the test timeout, so a single stuck step can’t use up the whole budget and hide behind the vaguer message. In February the 30 seconds was never the problem. It was the only clock on that click, so it was the one that went off.

Raise the timeout for long tests, not stuck ones
#

Raising the timeout is the right fix when a test does real work that takes a while. It’s the wrong one when a step is waiting for something that won’t happen, because a bigger number only makes the failure slower. The trace usually settles which you have. A steady run of actions that each finished, just a lot of them, is a long test. One action sitting on waiting for until the end is a stuck one.

For the long ones, add time where the length is rather than across the whole suite. The marketplace app’s suite has one smoke test that walks through the chat screen taking screenshots as it goes, and it gets three minutes:

test("chat: open, send a message, go back", async ({ page }) => {
  test.setTimeout(180_000);
  // ...
});

Called inside a describe, test.setTimeout() applies to every test in the group, which is how the job-cost test in the estimating app’s suite gets its minute. test.slow() triples whatever the timeout is, and can take a condition. In a beforeEach, testInfo.setTimeout() extends every test that runs the hook:

test("slow in WebKit", async ({ page, browserName }) => {
  test.slow(browserName === "webkit", "This page is slow in WebKit");
  // ...
});

test.beforeEach(async ({ page }, testInfo) => {
  testInfo.setTimeout(testInfo.timeout + 30_000);
});

Fixed sleeps are the other thing that eats the budget, quietly, on every run. The estimating app’s suite has more than a hundred page.waitForTimeout() calls, in nearly every spec file. The edit test above sleeps for three seconds before it even reaches the click, and no amount of sleeping would have saved it. Playwright’s API docs are blunt about this: “Never wait for timeout in production. Tests that wait for time are inherently flaky.” A web-first assertion already waits as long as it needs to, and no longer:

// before
await page.locator('[data-testid="save-template"]').click();
await page.waitForTimeout(1500);
await expect(page.getByText("E2E Edited Template")).toBeVisible();

// after
await page.locator('[data-testid="save-template"]').click();
await expect(page.getByText("E2E Edited Template")).toBeVisible();

Assertion timeouts need the same care. Both app suites raise expect.timeout to 15 seconds, and both run against a dev server rather than a production build. That buys slack, but two slow assertions in a row can spend the whole 30 seconds between them, and then the test timeout fires first and you’re back to the vague message.

When the clock isn’t the test’s
#

If the message mentions config.webServer, no test has run yet. Playwright starts your webServer command and waits for its url to answer before running anything, on a separate timeout of 60 seconds by default. Both app suites give the dev server two minutes:

webServer: {
  command: "npm run web -- --port 8081",
  url: "http://localhost:8081",
  reuseExistingServer: false,
  timeout: 120_000,
},

If your tests run against a deployed environment instead, find out how long its first request takes. When I finally measured mine, a machine starting from scale-to-zero on my host took 25 to 33 seconds to serve anything, which is most or all of a test’s budget before the first click. That story is in Scale to Zero, and Every Restart Is a 30-Second Outage.

If it says “No tests found” instead
#

No tests found sounds related, but it isn’t a timeout: Playwright looked and found nothing to run. By default it collects files matching **/*.@(spec|test).?(c|m)[jt]s?(x) under testDir, and testDir defaults to the folder the config file lives in. Mine set testDir: "./tests" next to the config, so a spec saved anywhere else is invisible. Arguments on the command line are regular expressions matched against file paths, which is why the error adds “Make sure that arguments are regular expressions matching test files.” To see what Playwright collected without running anything:

npx playwright test --list

If your spec isn’t in that list, it was never going to time out. And if you’re setting up Playwright for the first time, Visual Testing for Small Teams is my case for starting with screenshot comparisons.