Home / Blog / Playwright auto-waiting and timeouts
Playwright auto-waiting and timeouts: which one actually fired
Playwright waits in three places: before an action, inside a web-first assertion, and during a navigation. Each is bounded by a different clock — a test gets 30 seconds, an assertion 5, and actions and navigations have none of their own. The number in your failure message is usually the outermost clock, not the one you set.
A red run hands you one number and no name for it. The same 30-second failure can be a test that ran out while a click was still waiting, a click that ran out on a budget of its own, or a hook that never finished seeding data, and those three are repaired in three different files.
What does Playwright wait for automatically?
Three separate mechanisms travel under the name auto-waiting. They run on different clocks and they print different failure text.
The actionability checks, before an action
Before locator.click() does anything, Playwright checks one thing about the
locator and four about the element: the query has matched one node and no more, and the node is
visible, has stopped moving, is enabled, and will be the thing the click lands on. It re-runs that set until all of it holds,
and the documentation states the consequence directly: "If the required checks do not pass within the given
timeout, action fails with the TimeoutError."
The checks are not the same for every action, which matters before you conclude that an action
waited when it did not. locator.fill() checks visible, enabled and
editable, and skips stability and receives-events. locator.press() performs none of
them. locator.scrollIntoViewIfNeeded() waits for stability and nothing else.
The full grid of actions against checks is on the auto-waiting page of the documentation.
This mechanism runs on the action timeout, which has no default. An unbounded click therefore spends whatever is left of the test.
The retry loop inside a web-first assertion
await expect(locator).toBeVisible() re-runs its check against the live page until
the page agrees. That loop runs on the expect timeout, and the documentation gives the number:
"By default, the timeout for assertions is set to 5 seconds." Which matcher to reach
for, and what each one is quietly also satisfied by, is in
the assertions guide.
Navigation waiting
page.goto() returns once the page has fired its load event, which the
documentation defines as the whole page having loaded, including stylesheets, scripts, iframes
and images. A click that causes a navigation is waited on as part of the action. A click that
could cause several is the case the documentation singles out, recommending that you wait for a
specific URL with page.waitForURL().
The navigation guide also names a failure most people have met without having a word for it: hydration. The static page arrives, the button is on screen and enabled, the listeners have not been attached yet, and Playwright — which reads a page the way a very fast user does — clicks it. The click lands on nothing. Navigation waiting runs on the navigation timeout, which also has no default.
Which timeout just fired?
Playwright's timeout messages differ from one another in ways that name the clock. The difference is easy to walk past, because every one of them carries a number and the word timeout.
Each string in the left column below was pasted from a run of Playwright 1.62 against a small application built to be slow on purpose: a report that takes forty seconds to render its download button, a page whose response body never ends, a seed endpoint that sleeps for twenty seconds.
| What the run printed | The clock that ended it | Where that clock is set |
|---|---|---|
Error: locator.click: Test timeout of 30000ms exceeded. |
The test timeout. The click had not finished; the test ran out around it. | timeout in playwright.config.ts, or test.setTimeout() in the test body. |
TimeoutError: locator.click: Timeout 3000ms exceeded. |
The action timeout. The word Test is absent in front of the number. |
The { timeout } option on that call, or use: { actionTimeout }. |
Error: expect(locator).toBeVisible() failed above a line reading Timeout: 5000ms |
The expect timeout. The assertion retried for its whole budget and never passed. | expect: { timeout } in the config, or { timeout } on that one assertion. |
TimeoutError: page.goto: Timeout 5000ms exceeded. |
The navigation timeout. The call log under it names the URL it was navigating to and the lifecycle event it was waiting for. | The { timeout } option on goto, or use: { navigationTimeout }. |
"beforeAll" hook timeout of 5000ms exceeded. |
The hook timeout, which is separate from the test's. No test in the file ran. | test.setTimeout() called inside the hook. |
Timed out waiting 30s for the test suite to run, with 2 did not run in the summary |
The global timeout. Nothing about this failure is about the test that happened to be executing. | globalTimeout in the config. There is no per-test override. |
Rows one and two came from the same click on the same button. The first ran with no action
timeout set, so the test expired around a pending click; the second put
{ timeout: 3_000 } on the click inside a test with a minute to spend. The first of
them, whole:
import { test } from '@playwright/test';
test('downloads the finished report', async ({ page }) => {
await page.goto('/report');
await page.getByRole('button', { name: 'Download' }).click();
});
Playwright 1.62 · TypeScript · the button appears 40 seconds after load
x 1 rows\r1-test-timeout.spec.ts:3:5 › downloads the finished report (30.0s)
Test timeout of 30000ms exceeded.
Error: locator.click: Test timeout of 30000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Download' })
Playwright 1.62 · the run output, trimmed to the failure
Nothing in that message names the click's own clock, because the click never had one. Where the message alone will not settle it, two instruments will.
The call log attached to the error. By the time an action fails, Playwright
has already run the actionability checks, and the log lists what it saw:
"The log tells you if the element was visible, enabled and stable, if the locator resolved
to an element, scrolled into view, and so much more." The same page adds that
"If actionability can't be reached, it will show the action as pending." A log that
stops at waiting for getByRole(…) never found the element; a log that reaches
attempting click action found it and could not use it. The same log arrives in
the recorded trace.
The verbose API log. Run
DEBUG=pw:api npx playwright test and every API call the run makes is printed as it
happens, which is how you find out that the twenty seconds before the failure went on a fixture
nobody suspected.
What are Playwright's default timeouts?
Only three of the rows below have a default at all. The four that do not are where most of the surprises live.
| Timeout | Default | Where it is set | One failure it explains |
|---|---|---|---|
| Test | 30_000 ms |
{ timeout: 60_000 }; test.setTimeout(120_000) |
A run that stops mid-click and names the test rather than the click. |
| Expect | 5_000 ms |
{ expect: { timeout: 10_000 } }; expect(locator).toBeVisible({ timeout: 10_000 }) |
An assertion that was never going to pass costs five seconds on every run of the suite. |
| Action | no timeout | { use: { actionTimeout: 10_000 } }; locator.click({ timeout: 10_000 }) |
A click on an element that never arrives runs until the test dies; it never fails on its own. |
| Navigation | no timeout | { use: { navigationTimeout: 30_000 } }; page.goto('/', { timeout: 30_000 }) |
A page whose load event never fires holds the test until something outside it intervenes. |
| Global, for the whole run | no timeout | { globalTimeout: 3_600_000 } |
A wedged worker keeps a CI job alive until the CI platform's own limit kills it, with no report. |
beforeAll / afterAll hook |
30_000 ms |
test.setTimeout(60_000) inside the hook |
Seeding data in beforeAll runs out and every test in the file is reported without having run. |
| Fixture | none of its own — it shares the test's | { scope: 'test', timeout: 30_000 } on the fixture |
A slow login fixture spends the test's budget before the body starts, and the body takes the blame. |
The test timeout covers the test function, the fixture setups and the beforeEach
hooks together, so a slow fixture and a slow test look identical from outside. Teardown then
gets a second budget of the same size, shared between fixture teardowns and
afterEach.
Five of the seven are set in one file. This is that file, with each key commented for what it bounds, since the table above already gave the defaults.
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Bounds one test: its body, its fixture setup and its beforeEach hooks.
timeout: 30_000,
// Bounds one auto-retrying assertion, and nothing else.
expect: { timeout: 5_000 },
// Bounds the whole run, across every worker.
globalTimeout: 3_600_000,
use: {
channel: 'chrome',
baseURL: 'http://localhost:3200',
// Bounds one action: click, fill, hover, and the rest.
actionTimeout: 10_000,
// Bounds one navigation: goto, and a click that navigates.
navigationTimeout: 30_000,
},
webServer: {
command: 'node server.js',
url: 'http://localhost:3200/report',
reuseExistingServer: true,
},
});
Playwright 1.62 · playwright.config.ts · the config every sample on this page ran under
Why did the timeout you set not take effect?
The test timeout is the envelope. Every other clock on the page runs inside it, and none of them can outlive it.
So an action timeout longer than the time remaining in the test can never expire as an action
timeout. The test dies first, and the message names the test. This is the answer to the most
common report on the subject: actionTimeout goes up to 60 seconds, the run fails at
the same line, and the number in the failure has not moved. The same holds for an expect timeout
set above the test timeout.
The arithmetic is easier to watch at small numbers. The test gets five seconds and the click gets thirty.
import { test } from '@playwright/test';
test('downloads the finished report', async ({ page }) => {
test.setTimeout(5_000);
await page.goto('/report');
await page.getByRole('button', { name: 'Download' }).click({ timeout: 30_000 });
});
Playwright 1.62 · TypeScript · the test gets five seconds, the click gets thirty
x 2 nest\nesting.spec.ts:3:5 › downloads the finished report (5.0s)
Test timeout of 5000ms exceeded.
Error: locator.click: Test timeout of 5000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Download' })
Playwright 1.62 · the run output, trimmed to the failure
Five seconds, and the thirty-second click never gets to report anything. Raising a nested clock only ever does something once the clock above it has room.
Two clocks sit outside that rule and behave differently. test.setTimeout() called
inside a beforeEach hook changes the timeout of the test that hook is running for;
the documentation's own example adds to the existing budget with
testInfo.setTimeout(testInfo.timeout + 30_000). The beforeAll and
afterAll hooks have a timeout of their own that no test shares.
globalTimeout is not about a test at all. It stops the run wherever the run
happens to be, so the failure that lands in the report belongs to whichever test was unlucky,
and the tests behind it are reported as never having run. Reading that report as a bug in the
test at the top of it is a wasted afternoon.
What do I write instead of a fixed sleep?
The method people reach for is page.waitForTimeout(). Its API reference carries a
Discouraged badge and sends you elsewhere in the same breath: "Use signals such as
network events, selectors becoming visible and others instead." Those signals have names,
and each one answers a case that a sleep gets written for.
An animation or a transition
Mostly this is already handled. Stability is one of the actionability checks, so an element that is still moving is not clicked until it stops. The gap is the settled state of something you are not about to click — a dialog that is present, mid-fade, and holds the button you want. Assert the property that says it has settled.
import { test, expect } from '@playwright/test';
test('confirms the deletion once the dialog has settled', async ({ page }) => {
await page.goto('/modal');
await page.getByRole('button', { name: 'Delete account' }).click();
const confirm = page.locator('#confirm');
await expect(confirm).toHaveCSS('opacity', '1');
await confirm.getByRole('button', { name: 'Yes, delete it' }).click();
});
Playwright 1.62 · TypeScript · passes against a 400 ms fade
toHaveCSS is an auto-retrying matcher, so it polls the computed value until the
transition lands. The 400 ms is never written down anywhere, so changing the transition to 900 ms
leaves the test alone.
A debounce
Search-as-you-type, a filter that fires 300 ms after the last keystroke. Assert on the result
the debounce produces, or wait for the request it fires with
page.waitForResponse. The ordering matters: the promise is created before the
interaction and awaited after it, which is how all three samples on the network guide are
written. A response that arrives before you started listening is a response you never see.
import { test, expect } from '@playwright/test';
test('search results arrive after the debounce', async ({ page }) => {
await page.goto('/search');
// Note no await: the promise starts listening before the typing that causes the request.
const searchResponse = page.waitForResponse('**/api/search*');
await page.getByLabel('Search').fill('ada');
await searchResponse;
await expect(page.getByRole('listitem').first()).toHaveText('Ada Lovelace');
});
Playwright 1.62 · TypeScript · passes against a 300 ms debounce and a 600 ms endpoint
A third-party widget that loads late
A payment iframe, a chat launcher, a consent banner. Put the extra patience on the one
assertion that needs it:
await expect(launcher).toBeVisible({ timeout: 20_000 }). Where the first attempt is
expected to fail outright and a retry is what fixes it, wrap the check in
expect.toPass({ timeout }), which retries a block of code until it stops throwing.
Give it an explicit timeout: the documentation notes that toPass defaults to a
timeout of 0 and does not respect a custom expect timeout. Reaching inside the widget is
a frame-locator question.
Something with no element to wait on
A background job, a webhook, a row that a worker writes, an email. expect.poll
turns any ordinary assertion into a polling one, so the thing being waited for can be an HTTP
response rather than a node in a page.
import { test, expect } from '@playwright/test';
test('the export job finishes', async ({ request }) => {
test.setTimeout(60_000);
await expect.poll(async () => {
const response = await request.get('http://localhost:3200/api/export/42');
return (await response.json()).status;
}, {
message: 'export 42 should finish',
intervals: [1_000, 2_000, 5_000],
timeout: 30_000,
}).toBe('done');
});
Playwright 1.62 · TypeScript · passes against a job that finishes after 8 seconds
intervals sets the gaps between probes and the last value repeats, so this one
probes immediately, then at one second, three, eight, and every five seconds after that — which
is what a logging run of it reports. Note that
test.setTimeout(60_000) is there because the poll's own 30 seconds would otherwise
be cut short by the 30-second test around it. A fixed sleep here is less reliable than the poll,
not merely untidier: five seconds is too long on a good day and too short on a bad one.
A timer inside the application
An inactivity logout, a session that expires, a banner scheduled to appear later.
page.clock installs a fake clock in the browser and lets the test move it. A wait
the product measures in minutes becomes a test that finishes in well under a second — the run
below reported 396 ms and 283 ms on two consecutive passes on one machine.
import { test, expect } from '@playwright/test';
test('signs the user out after five idle minutes', async ({ page }) => {
await page.clock.install();
await page.goto('/session');
await page.clock.fastForward('05:00');
await expect(page.getByText('Signed out after 5 minutes of inactivity.')).toBeVisible();
});
Playwright 1.62 · TypeScript · passes against a five-minute idle timer
The clock guide is strict about ordering: if install is called at all, it has to
come before anything else in the test touches a timer or a date.
When the answer is a bigger number
A timeout expiring tells you an operation did not finish. Raising the number changes what you are prepared to wait for and leaves the operation exactly as it was, so a raise made before you know which of the clocks above fired buys a slower red run.
Cases where raising it is correct have a shape: the operation is genuinely slow and you can say why — a report that builds, a browser install on a cold CI machine, a migration that runs on boot. Why the application is slow is a question about the application, and this page stops at that boundary.
When it is right, raise it in the narrowest scope that covers the case —
test.setTimeout() on the one test, or a { timeout } on the one call. A
raise in the config is paid by every future failure in the suite, because a suite whose clocks
are all two minutes takes two minutes to tell you anything is wrong, and it charges that to the
whole team rather than to the spec that needed it.
The documentation's own note above the action and navigation timeouts reads: "These are the low-level timeouts that are pre-configured by the test runner, you should not need to change these. If you happen to be in this section because your test are flaky, it is very likely that you should be looking for the solution elsewhere." The grammatical slip is in the original, and the position is Microsoft's.
When auto-waiting bites you
These go wrong often enough to recognise by sight.
The element is visible and the click still times out
Something invisible is on top of it: a full-screen container left behind by a closed modal, a cookie banner with a transparent backdrop, a sticky header. Receives-events is the check that failed, and the call log names the culprit by tag.
TimeoutError: locator.click: Timeout 5000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Pay now' })
- locator resolved to <button id="pay">Pay now</button>
- attempting click action
2 × waiting for element to be visible, enabled and stable
- element is visible, enabled and stable
- scrolling into view if needed
- done scrolling
- <div id="banner"></div> intercepts pointer events
Playwright 1.62 · the call log, trimmed to one retry cycle
An assertion that the button is visible passed on the line above this click. Visibility was never the problem.
The animation that never ends
A spinner set to loop forever has a bounding box that never settles, so nothing acts on it or on anything moving with it. The log says exactly that.
TimeoutError: locator.click: Timeout 5000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Retry' })
- locator resolved to <button>Retry</button>
- attempting click action
2 × waiting for element to be visible, enabled and stable
- element is not stable
Playwright 1.62 · the call log, trimmed to one retry cycle
force: true makes this failure disappear by disabling the non-essential checks,
and the click still may not do what a user's click would do. The documentation has a
Forcing actions section; read it before reaching for the option, and treat a permanent
animation over an interactive control as a defect in the page.
The assertion that was never going to pass
It still spends its full five seconds, every run, before it fails or before a soft assertion lets the test carry on. Twenty of those in a suite is a hundred seconds of waiting that produces nothing.
Waiting after the thing has already happened
A waitForResponse promise created after the click sits listening for a response
that arrived while the previous line was running. It fails at whatever clock is nearest, with a
message that points at the wait and says nothing about the ordering.
TimeoutError: page.waitForResponse: Timeout 5000ms exceeded while waiting for event "response"
=========================== logs ===========================
waiting for response "**/api/search*"
============================================================
Playwright 1.62 · the same spec as the debounce sample, with the two lines swapped
Selenium-era waiting carried across
An explicit wait for a selector, then an action on the same selector: two waits where one was needed, and only the second is doing anything, since the action re-runs the checks from scratch anyway. Deleting the first line makes the spec shorter and leaves the waiting where it was. Whether the selector itself should survive the move is a separate decision.
A suite that waits everywhere
Some readers will recognise the whole page in their own repository: sleeps scattered through hundreds of specs, a global timeout raised twice by two people who have both left, and a run that takes forty minutes to go red. That is a different problem from the one you arrived with, and it is the one we are usually called about when a suite has stopped being believed — a failure reported as a timeout is one of the most common things we find that turns out to be something else underneath.
Questions
What is the default timeout in Playwright?
A test gets 30,000 ms and an auto-retrying assertion gets 5,000 ms. Actions, navigations, fixtures and the run as a whole have no timeout of their own, which is the part that surprises people: a click with no limit set on it runs until the test it sits in runs out. The beforeAll and afterAll hooks are the exception among the rest, with 30,000 ms each.
Does Playwright wait for an element to appear automatically?
Yes, as part of the action or the assertion that uses the locator. Before a click, Playwright re-checks that the query has matched one node and no more, and that the node is visible, has stopped moving, is enabled and will be the thing the click lands on, and it keeps re-checking until a clock stops it. A locator on its own does nothing until something uses it, so declaring one waits for nothing at all.
How do I increase the timeout for a single test?
Call test.setTimeout(120_000) at the top of the test body, which replaces the test timeout for that test alone. Work out which clock expired first, because a bigger number does not change what the operation was doing when it ran out. Raise it where the operation is genuinely slow and you can say why, and put the raise on the one test that needs it.
Why does my test still fail at 30 seconds when I set a 60-second action timeout?
Because the test timeout is the envelope and every other clock runs inside it. A 60-second action timeout in a 30-second test can never expire as an action timeout: the test is already over at 30 seconds, and the message you get names the test. Raise the test timeout as well, or the action timeout on its own changes nothing.
The sleeps already in your repository
One grep gives you the count in a second. The harder number is how many of them
anybody can still explain — which page each one was guessing about, and whether that page
still exists. Where those same specs also fail at random, we start from the failures: every
row carries a cause from four — locator strategy, a real race in the product, test data, or
environment — and a disposition from three: fixed, quarantined, or referred to the product
team. Send us the count and the two oldest sleeps you can find.