Introduction
"Test timeout of 30000ms exceeded." is one of the most searched Playwright errors, and it's rarely the whole story. Playwright has several timeouts, each with its own default and its own message, and the right fix depends on which one fired.
We triggered every one of them on 8 October 2026 with Playwright 1.63.0, against a small local app that is slow on purpose, and against Sauce Demo's slow login. This page shows each error as Playwright printed it, how to tell them apart from the first line, and what to change.
Which timeout fired?
Read the first line of the error:
| First line of the error | Timeout | Default | Change it with |
|---|---|---|---|
expect(locator)…failed with Timeout: 5000ms |
Expect (assertion) | 5 seconds | expect: { timeout } in the config, or { timeout } on one assertion |
Test timeout of 30000ms exceeded. |
Test | 30 seconds | timeout in the config, test.setTimeout(), test.slow() |
Test timeout of 30000ms exceeded while running "beforeEach" hook. |
Test, spent in a hook | Shared with the test | The same as the test timeout |
TimeoutError: locator.click: Timeout 10000ms exceeded. |
Action | None | use: { actionTimeout }, or { timeout } on one action |
TimeoutError: page.goto: Timeout 10000ms exceeded. |
Navigation | None | use: { navigationTimeout }, or { timeout } on one navigation |
Timed out waiting 10s for the test suite to run |
Global (the whole run) | None | globalTimeout in the config |
Error: Timed out waiting 5000ms from config.webServer. |
Web server start-up | 60 seconds | webServer: { timeout } |
The defaults are from Playwright's timeouts documentation, except the web server's, which is in Playwright's own type definitions for webServer.timeout. "None" means the step has no limit of its own and runs until the test timeout stops it.
The app and the spec
The app is one file. /slow?ms=N answers after N milliseconds. The home page has a Save button whose "Saved" message appears after ?ms=N, and a Pay button that is always disabled. Save it as server.mjs:
// A small app that is slow on purpose. GET /slow?ms=N answers after N ms;
// / has a Save button whose "Saved" message appears after ?ms=N (default 7000).
import http from "node:http";
const page = (ms) => `<!doctype html><title>Slow app</title>
<button id="save">Save</button><p id="status" role="status"></p>
<button id="pay" disabled>Pay</button>
<script>
document.getElementById("save").onclick = () =>
setTimeout(() => (document.getElementById("status").textContent = "Saved"), ${ms});
</script>`;
http
.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
const ms = Number(url.searchParams.get("ms") ?? 7000);
if (url.pathname === "/slow") {
setTimeout(() => res.end(`<!doctype html><title>Finally</title><h1>Loaded after ${ms} ms</h1>`), ms);
return;
}
res.setHeader("content-type", "text/html");
res.end(page(ms));
})
.listen(4321, () => console.log("slow app on http://localhost:4321"));The config starts it for every run:
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
reporter: "list",
use: { baseURL: "http://localhost:4321", testIdAttribute: "data-test" },
webServer: { command: "node server.mjs", url: "http://localhost:4321" },
});And tests/timeouts.spec.ts makes each timeout fire:
import { test, expect } from "@playwright/test";
test("expect timeout: the message comes after 7 seconds", async ({ page }) => {
await page.goto("/?ms=7000");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("status")).toHaveText("Saved");
});
test("test timeout: a page that takes 40 seconds", async ({ page }) => {
await page.goto("/slow?ms=40000");
await expect(page.getByRole("heading")).toBeVisible();
});
test("action timeout: a button that never becomes enabled", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Pay" }).click();
await expect(page.getByRole("status")).toHaveText("Paid");
});
test.describe("with action and navigation timeouts set", () => {
test.use({ actionTimeout: 10_000, navigationTimeout: 10_000 });
test("action timeout: the same button, 10-second limit", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Pay" }).click();
await expect(page.getByRole("status")).toHaveText("Paid");
});
test("navigation timeout: a page that takes 20 seconds", async ({ page }) => {
await page.goto("/slow?ms=20000");
await expect(page.getByRole("heading")).toBeVisible();
});
});
test.describe("hooks", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/slow?ms=40000");
});
test("hook timeout: the beforeEach page takes 40 seconds", async ({ page }) => {
await expect(page.getByRole("heading")).toBeVisible();
});
});
test("an expect timeout longer than the test timeout", async ({ page }) => {
await page.goto("/?ms=60000");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByRole("status")).toHaveText("Saved", { timeout: 60_000 });
});All seven failed, each in its own way:
Running 7 tests using 1 worker
x 1 tests\timeouts.spec.ts:3:5 › expect timeout: the message comes after 7 seconds (5.4s)
x 2 tests\timeouts.spec.ts:9:5 › test timeout: a page that takes 40 seconds (35.0s)
x 3 tests\timeouts.spec.ts:14:5 › action timeout: a button that never becomes enabled (30.0s)
x 4 tests\timeouts.spec.ts:23:7 › with action and navigation timeouts set › action timeout: the same button, 10-second limit (10.2s)
x 5 tests\timeouts.spec.ts:29:7 › with action and navigation timeouts set › navigation timeout: a page that takes 20 seconds (15.2s)
x 6 tests\timeouts.spec.ts:40:7 › hooks › hook timeout: the beforeEach page takes 40 seconds (35.0s)
x 7 tests\timeouts.spec.ts:45:5 › an expect timeout longer than the test timeout (30.0s)1. Expect timeout: the assertion gave up after 5 seconds
The "Saved" message appears 7 seconds after the click, and an assertion waits 5:
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('status')
Expected: "Saved"
Received: ""
Timeout: 5000ms
Call log:
- Expect "toHaveText" getByRole('status') with timeout 5000ms
- waiting for getByRole('status')
14 × locator resolved to <p id="status" role="status"></p>
- unexpected value ""
4 | await page.goto("/?ms=7000");
5 | await page.getByRole("button", { name: "Save" }).click();
> 6 | await expect(page.getByRole("status")).toHaveText("Saved");
| ^
7 | });
8 |
9 | test("test timeout: a page that takes 40 seconds", async ({ page }) => {The Timeout: 5000ms line and the call log tell you this was the assertion's own limit, and 14 × … unexpected value "" that Playwright checked 14 times and the text was still empty each time. The test itself had plenty of time left.
2. Test timeout, and the net::ERR_ABORTED that follows it
A page that takes 40 seconds to load ran into the 30-second test timeout:
Test timeout of 30000ms exceeded.
Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
Call log:
- navigating to "http://localhost:4321/slow?ms=40000", waiting until "load"
8 |
9 | test("test timeout: a page that takes 40 seconds", async ({ page }) => {
> 10 | await page.goto("/slow?ms=40000");
| ^
11 | await expect(page.getByRole("heading")).toBeVisible();
12 | });
13 |There are two errors here, and the first is the cause. When the test ran out of time, Playwright closed the page, and the page.goto that was still waiting failed with net::ERR_ABORTED; maybe frame was detached?. If you search for that second line, you'll be looking for a network problem that isn't there. Look at what the step was waiting for instead: navigating to "…/slow?ms=40000", waiting until "load".
3. Actions have no timeout of their own
Clicking a button that stays disabled doesn't fail at the click. With no action timeout, the click retried until the whole test ran out:
Test timeout of 30000ms exceeded.
Error: locator.click: Test timeout of 30000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Pay' })
- locator resolved to <button id="pay" disabled>Pay</button>
- attempting click action
2 × waiting for element to be visible, enabled and stable
- element is not enabled
- retrying click action
- waiting 20ms
2 × waiting for element to be visible, enabled and stable
- element is not enabled
- retrying click action
- waiting 100ms
57 × waiting for element to be visible, enabled and stable
- element is not enabled
- retrying click action
- waiting 500ms
14 | test("action timeout: a button that never becomes enabled", async ({ page }) => {
15 | await page.goto("/");
> 16 | await page.getByRole("button", { name: "Pay" }).click();
| ^
17 | await expect(page.getByRole("status")).toHaveText("Paid");
18 | });
19 |The call log says why, element is not enabled, 61 times over. The headline only says the test was too slow. With actionTimeout: 10_000, the same click fails in 10 seconds and names itself:
TimeoutError: locator.click: Timeout 10000ms exceeded.
Call log:
- waiting for getByRole('button', { name: 'Pay' })
- locator resolved to <button id="pay" disabled>Pay</button>
- attempting click action
2 × waiting for element to be visible, enabled and stable
- element is not enabled
- retrying click action
- waiting 20ms
2 × waiting for element to be visible, enabled and stable
- element is not enabled
- retrying click action
- waiting 100ms
19 × waiting for element to be visible, enabled and stable
- element is not enabled
- retrying click action
- waiting 500ms
23 | test("action timeout: the same button, 10-second limit", async ({ page }) => {
24 | await page.goto("/");
> 25 | await page.getByRole("button", { name: "Pay" }).click();
| ^
26 | await expect(page.getByRole("status")).toHaveText("Paid");
27 | });
28 |That's the case for setting an action timeout in your config even though there's no default: a failure that names the step is quicker to read than one that blames the whole test.
4. Navigation timeout
With navigationTimeout: 10_000, a 20-second page load fails at the navigation:
TimeoutError: page.goto: Timeout 10000ms exceeded.
Call log:
- navigating to "http://localhost:4321/slow?ms=20000", waiting until "load"
28 |
29 | test("navigation timeout: a page that takes 20 seconds", async ({ page }) => {
> 30 | await page.goto("/slow?ms=20000");
| ^
31 | await expect(page.getByRole("heading")).toBeVisible();
32 | });
33 | });waiting until "load" matters here. page.goto waits for the load event by default, so a page whose HTML arrives quickly but whose images, fonts or scripts take long can still hit this. If the test only needs the document, page.goto(url, { waitUntil: "domcontentloaded" }) waits for less.
5. Hooks spend the test's time
The beforeEach hook navigates to the 40-second page. The test body never starts:
Test timeout of 30000ms exceeded while running "beforeEach" hook.
34 |
35 | test.describe("hooks", () => {
> 36 | test.beforeEach(async ({ page }) => {
| ^
37 | await page.goto("/slow?ms=40000");
38 | });
39 |
Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
Call log:
- navigating to "http://localhost:4321/slow?ms=40000", waiting until "load"
35 | test.describe("hooks", () => {
36 | test.beforeEach(async ({ page }) => {
> 37 | await page.goto("/slow?ms=40000");
| ^
38 | });
39 |
40 | test("hook timeout: the beforeEach page takes 40 seconds", async ({ page }) => {while running "beforeEach" hook says where the time went. The beforeEach hook, fixture setup and the test body share one 30-second budget; a slow login in a hook leaves less for the test. beforeAll and afterAll get their own budget of the same size.
6. A longer expect timeout doesn't beat the test timeout
An assertion given 60 seconds still stops at the test's 30:
Test timeout of 30000ms exceeded.
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('status')
Expected: "Saved"
Received: ""
Call log:
- Expect "toHaveText" getByRole('status') with timeout 60000ms
- waiting for getByRole('status')
62 × locator resolved to <p id="status" role="status"></p>
- unexpected value ""
- Test timeout of 30000ms exceeded.
46 | await page.goto("/?ms=60000");
47 | await page.getByRole("button", { name: "Save" }).click();
> 48 | await expect(page.getByRole("status")).toHaveText("Saved", { timeout: 60_000 });
| ^
49 | });
50 |The call log shows the assertion's own limit, with timeout 60000ms, and then Test timeout of 30000ms exceeded.. Raising one assertion's timeout above the test timeout does nothing; raise the test's timeout too, with test.slow() or test.setTimeout(), if the step really takes that long.
7. The global timeout and the web server
These two stop the whole run rather than one test. With globalTimeout: 10_000 in the config and a test that waits 20 seconds, the run ended like this:
Running 1 test using 1 worker
Timed out waiting 10s for the test suite to run
Timed out waiting 10s for the teardown for test suite to run
1 did not runPlaywright's documentation shows the message as "Timed out waiting 3600s for the entire test run". Our 1.63.0 run said "for the test suite to run" instead, and reported the test that was cut off as "did not run". Search for both wordings.
With a webServer command that never starts listening and timeout: 5_000, no test ran at all:
Error: Timed out waiting 5000ms from config.webServer.The web server's default is 60 seconds. A command that builds the app before starting it can need more, and that's a reason to raise it. A wrong url or port fails the same way however high you set it, because Playwright keeps checking an address the server never answers on.
A 5-second login against a 5-second limit
Sauce Demo's performance_glitch_user is slow on purpose, and it's slow in an unusual way. We timed 23 logins: 10 took between 5,064 and 5,120 ms, and 13 took under 65 ms. In the six logins we timed step by step, the URL had already changed to /inventory.html when the click returned; the five seconds were spent with the page busy.
That makes the default 5-second expect timeout race the site. This spec passed 20 times out of 20 in our run:
import { test, expect } from "@playwright/test";
// Sauce Demo publishes this password on its own login page.
const PASSWORD = process.env.SAUCE_PASSWORD ?? "secret_sauce";
test("performance_glitch_user reaches the products page", async ({ page }) => {
await page.goto("https://www.saucedemo.com/");
await page.getByPlaceholder("Username").fill("performance_glitch_user");
await page.getByPlaceholder("Password").fill(PASSWORD);
await page.getByRole("button", { name: "Login" }).click();
await expect(page).toHaveURL("https://www.saucedemo.com/inventory.html");
});It passed for a reason you shouldn't rely on. Timing the steps in six more runs showed two patterns: either the click itself took about 5,090 ms and the assertion then passed in 20 to 40 ms, or the click returned in about 50 ms and toHaveURL passed after 5,015 to 5,027 ms, a few milliseconds past its own 5,000 ms limit, because the busy page couldn't answer the check any sooner. A slightly slower machine or a slightly slower day turns that into an intermittent failure. Give the check a stated limit instead, as the login page test cases spec does with { timeout: 10_000 } and a comment saying why.
How to fix a timeout
Start with the cause, not the number.
Check the state you're waiting for. The disabled Pay button timed out after 30 seconds as a click. Asserting that it's enabled first, in tests/disabled.spec.ts, fails in 5 seconds with the reason in the first line:
import { test, expect } from "@playwright/test";
test("a disabled button, checked before the click", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("button", { name: "Pay" })).toBeEnabled();
await page.getByRole("button", { name: "Pay" }).click();
});Error: expect(locator).toBeEnabled() failed
Locator: getByRole('button', { name: 'Pay' })
Expected: enabled
Received: disabled
Timeout: 5000ms
Call log:
- Expect "toBeEnabled" getByRole('button', { name: 'Pay' }) with timeout 5000ms
- waiting for getByRole('button', { name: 'Pay' })
14 × locator resolved to <button id="pay" disabled>Pay</button>
- unexpected value "disabled"
3 | test("a disabled button, checked before the click", async ({ page }) => {
4 | await page.goto("/");
> 5 | await expect(page.getByRole("button", { name: "Pay" })).toBeEnabled();
| ^
6 | await page.getByRole("button", { name: "Pay" }).click();
7 | });
8 |The test still fails, as it should: the button is disabled. It now fails 25 seconds sooner, and says so.
When a step is slow by design, give that step a stated limit. If saving really takes up to 10 seconds by requirement, say so on that assertion, and if a page really takes 40 seconds, mark that test as slow. tests/fixed.spec.ts:
import { test, expect } from "@playwright/test";
test("a slow message, with a stated limit for that one check", async ({ page }) => {
await page.goto("/?ms=7000");
await page.getByRole("button", { name: "Save" }).click();
// Saving takes up to 10 seconds by the app's own requirement; nothing else gets more time.
await expect(page.getByRole("status")).toHaveText("Saved", { timeout: 10_000 });
});
test("a slow page, with a slow test", async ({ page }) => {
test.slow();
await page.goto("/slow?ms=40000");
await expect(page.getByRole("heading")).toHaveText("Loaded after 40000 ms");
});Running 2 tests using 1 worker
ok 1 tests\fixed.spec.ts:3:5 › a slow message, with a stated limit for that one check (7.3s)
ok 2 tests\fixed.spec.ts:10:5 › a slow page, with a slow test (40.1s)
2 passed (49.5s)
2 passed (49.5s)test.slow() triples the test timeout, to 90 seconds here. Both limits are written next to the step that needs them, with a reason, so a step that gets slower than its requirement still fails.
Set action and navigation timeouts in the config. Neither has a default, so a stuck click or navigation is reported as a test timeout. A limit such as actionTimeout: 10_000 makes the failure name the step, as in example 3.
Don't raise the global numbers to make failures go away. A bigger timeout or expect.timeout in the config makes every failing test take longer to fail and hides the step that got slow. And a fixed wait such as page.waitForTimeout(5000) makes things worse: it waits the full time on every run, and still fails on the day the step takes 5.1 seconds. Our Playwright test reviewer flags fixed waits for that reason.
Exercise
Change the Save test in tests/timeouts.spec.ts so it passes without touching any timeout: what would the app have to do differently? Then set expect: { timeout: 3_000 } in the config and predict which of the seven tests fail differently, before you run them.
For tests that fail for reasons other than time, the diagnose case files walk through reading a failure from its output alone.
Conclusion
A Playwright timeout error tells you which limit ran out in its first line: the assertion's 5 seconds, the test's 30, or an action, navigation, hook, global or web server limit. Read that line, ignore the side-effect errors that follow it, and look at what the call log says the step was waiting for. Then fix the reason the step was slow, check the state you need before acting on it, and give a step that is slow by design its own stated limit. Raising every timeout only makes the next failure slower to arrive.
Sources and further reading
- Playwright: Timeouts, the defaults and how to change each one.
- Playwright: test.slow(), which triples the test timeout.
- Playwright: TestConfig.webServer, for the start-up timeout and
url. - Playwright: page.goto, on
waitUntiland the navigation timeout. - Sauce Demo, where the
performance_glitch_userlogins were timed.