---
title: "Playwright page.goto Slow? Fix Navigation Time in CI"
description: "Why Playwright page.goto is slow in CI, and how to fix it: time each navigation, pick a waitUntil, test a production build and skip repeat logins."
canonical: "https://currents.dev/posts/playwright-page-goto-slow-fix-navigation-time-in-ci"
updated: "2026-10-30"
---

# Playwright page.goto Slow? Fix Navigation Time in CI

Published 2026-10-30 by Walter Galvão.

Canonical URL: https://currents.dev/posts/playwright-page-goto-slow-fix-navigation-time-in-ci

Why Playwright page.goto is slow in CI, and how to fix it: time each navigation, pick a waitUntil, test a production build and skip repeat logins.

---


> **TL;DR**
>
> By default, `page.goto()` waits for the load event, and the load event fires only once the whole page has loaded, including stylesheets, scripts, iframes and images. Time every `goto`, fix the slowest pages first, and keep a change only when the timings drop.
>
> 1. Override the built-in `page` fixture so every `goto` records its own duration. After a CI run, fix the slowest few pages first.
> 2. Use `domcontentloaded` as the suite default. Use `commit` only when a readiness check follows it.
> 3. Point `webServer` at a production build in CI and keep the dev server for local runs.
> 4. Log in once in a setup test and save the state. With the session loaded, go straight to the deep URL the test needs instead of the home page and a walk through the menu.
> 5. Route only the third-party hosts, abort them, and do it in the shared fixture so every test gets it. Keep the blocking only if the navigations got faster.

By default, `page.goto()` waits for the load event. The load event fires only once the whole page has loaded, including stylesheets, scripts, iframes and images. The [DOMContentLoaded event](https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event) fires once the HTML document has been completely parsed and all deferred scripts have downloaded and executed, and that event doesn't wait for images, subframes or async scripts to finish loading.

Playwright creates a new browser context for each test. Every test that opens the page loads it in its own context. Across a large suite, one slow page becomes a line item on your CI bill.

We'd expect a raised timeout to get the test green. It also keeps every slow second in the suite and hides which page was slow.

The real fix is to find the slow pages and stop paying for them in every test.

## What page.goto waits for

A bare `page.goto(url)` is the same call as `page.goto(url, { waitUntil: 'load' })`. The `waitUntil` option decides when a navigation counts as finished, and it defaults to `load`.

The wait has two halves. A navigation commits once the response headers are parsed and session history is updated, and only then does the page start loading the document. [Loading](https://playwright.dev/docs/navigations) covers getting the rest of the response body over the network, parsing, running scripts and firing load events.

In the test below, both `goto` calls wait for the same thing. The comments list three of the `waitUntil` values, earliest first:

```ts
import { test } from '@playwright/test';

test('default goto waits for the load event', async ({ page }) => {
  // Equivalent calls: waitUntil defaults to 'load'.
  await page.goto('http://localhost:3000/dashboard');
  await page.goto('http://localhost:3000/dashboard', { waitUntil: 'load' });

  // 'commit'           response received, document started loading
  // 'domcontentloaded' the DOMContentLoaded event fired
  // 'load'             the load event fired: stylesheets, scripts, iframes, images
});
```

That split tells you where to look. A slow commit points at the server or the network, because nothing past the response headers has happened yet. A long gap between commit and load points at what the page pulls in afterwards: third-party scripts, fonts and images.

If the page does a client-side redirect before `load`, `page.goto()` waits for the redirected page to fire the `load` event. When the server sends multiple redirects, the navigation resolves with the first non-redirect response.

One bug report describes an error in Firefox. In that report, a goto failed with an NS_BINDING_ABORTED error when the page started a navigation of its own at the same time ([#20749](https://github.com/microsoft/playwright/issues/20749)). A maintainer replied that this matches what Firefox itself does. The issue is still open. If a Firefox-only failure names that error, look for a script on the page that navigates by itself.

## Timing every navigation

Every fix below trades something, so measure before you change anything.

<AntiPattern title="Tuning before measuring" id="anti-pattern-tuning-before-measuring">

A `goto` times out, so someone raises the timeout or switches that one call to `domcontentloaded`. The test passes. Nobody can say which pages were slow, by how much, or whether the change made anything faster, and the next slow page gets the same patch.

<AntiPattern.Fix>

Override the built-in `page` fixture so every `goto` records its own duration. [Overriding built-in fixtures](https://playwright.dev/docs/test-fixtures) is a documented pattern, and the docs' own example overrides `page`. The fixture below wraps `goto` and adds an annotation with the duration and URL to the running test:

```ts
// fixtures.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  page: async ({ page }, use) => {
    const goto = page.goto.bind(page);
    page.goto = async (url, options) => {
      const start = Date.now();
      try {
        return await goto(url, options);
      } finally {
        // Recorded even when the navigation throws.
        base.info().annotations.push({
          type: 'goto',
          description: `${Date.now() - start}ms ${url}`,
        });
      }
    };
    await use(page);
  },
});

export { expect } from '@playwright/test';
```

`base` is Playwright's `test` under another name, and while the test is already running, you can add annotations to `test.info().annotations`. The wrapper keeps the signature of `goto`, so test bodies stay as they are: switch their import from `@playwright/test` to `./fixtures` and every `goto` call on the `page` fixture gets timed.

After a CI run, group the `goto` annotations by URL and sort them by duration. Fix the slowest few pages first.

</AntiPattern.Fix>

</AntiPattern>

## Choosing a waitUntil

Once you know which pages are slow, the cheapest fix is to stop waiting for resources the test never touches. These are the `waitUntil` values [`page.goto()` accepts](https://playwright.dev/docs/api/class-page):

- `commit`: finished when the network response is received and the document has started loading.
- `domcontentloaded`: finished when the DOMContentLoaded event fires.
- `load`: the navigation finishes when the load event fires, and `load` is the default.
- `networkidle`: finished when there have been no network connections for at least 500 ms. The docs mark it as discouraged.

Use `domcontentloaded` as the suite default. Use `commit` only when a readiness check follows it. Stopping early works for actions because Playwright waits for each action's target element to become actionable, so you can interact with the page at any moment.

Keep `load` when the test exists to prove that the page's subresources load, such as a synthetic check that a third-party support widget still arrives. In that test, waiting for every script is the point.

<AntiPattern title="Networkidle as ready" id="anti-pattern-networkidle-as-ready">

Waiting for `networkidle` sounds like waiting for the page to be done. Every navigation then waits for at least 500 ms with no network connections, and a page that polls more often than every 500 ms never gets that quiet window, so the navigation never finishes on its own. The docs say not to use it for testing and to rely on web assertions to assess readiness instead.

```ts
// Anti-pattern: 500 ms of network quiet on every navigation, and never on a polling page
import { test, expect } from '@playwright/test';

test('dashboard shows recent orders', async ({ page }) => {
  await page.goto('http://localhost:3000/dashboard', { waitUntil: 'networkidle' });
  await expect(page.getByRole('heading', { name: 'Recent orders' })).toBeVisible();
});
```

<AntiPattern.Fix>

Stop at `domcontentloaded` and put the readiness check on the element the test needs:

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

test('dashboard shows recent orders', async ({ page }) => {
  await page.goto('http://localhost:3000/dashboard', { waitUntil: 'domcontentloaded' });
  await expect(page.getByRole('heading', { name: 'Recent orders' })).toBeVisible();
});
```

</AntiPattern.Fix>

</AntiPattern>

## Hydration readiness checks

Stopping early trades slow tests for a different risk: clicking before the page can respond. The Playwright [navigation docs](https://playwright.dev/docs/navigations) put it plainly: there is no way to tell that a page is loaded, because it depends on the page and the framework. Readiness has to come from a signal the app gives.

Playwright starts interacting with the page the moment it sees it. If a button is enabled but its listeners haven't been added yet, Playwright clicks it and the click has no effect.

<AntiPattern title="Clicking before hydration" id="anti-pattern-clicking-before-hydration">

The test stops at `commit` and clicks the submit button straight away. The button is enabled, so the click goes through, but no handler is attached yet and nothing happens. The tempting patch is a fixed sleep after `goto`, which costs its full length on every test and still flakes on the run where hydration takes longer than the sleep.

```ts
// Anti-pattern: the button is enabled before hydration, so this click can do nothing
import { test, expect } from '@playwright/test';

test('user can create an account', async ({ page }) => {
  await page.goto('http://localhost:3000/signup', { waitUntil: 'commit' });
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();
});
```

<AntiPattern.Fix>

The docs put the fix in the app: keep all interactive controls disabled until hydration finishes and the page is fully functional. That gives the test a signal. `expect(locator).toBeEnabled()` asserts that the locator points to an enabled element; with the controls disabled until after hydration, an enabled button means the page is fully functional:

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

test('user can create an account', async ({ page }) => {
  await page.goto('http://localhost:3000/signup', { waitUntil: 'commit' });

  // The app keeps this button disabled until hydration attaches its handlers.
  const submit = page.getByRole('button', { name: 'Create account' });
  await expect(submit).toBeEnabled();

  await page.getByLabel('Email').fill('user@example.com');
  await submit.click();
  await expect(page.getByText('Check your inbox')).toBeVisible();
});
```

If your app ships enabled controls before hydration, that's the change to ask your frontend team for. The test can't fix it from the outside.

</AntiPattern.Fix>

</AntiPattern>

### Readiness signals

| What the test needs | Check |
|---|---|
| A control it will use | `toBeEnabled()` on that control, with the app keeping it disabled until hydration |
| Rendered content | A web assertion on that content |
| One element to act on | The action itself, which waits for its target to become actionable |
| The page as a whole | No such signal exists, so pick one of the rows above |
| A fixed sleep | Only while debugging, never in a committed test |

## A shared goto fixture

A `waitUntil` choice copied into hundreds of `goto` calls is a choice nobody can change.

<AntiPattern title="WaitUntil in every call" id="anti-pattern-waituntil-in-every-call">

Each test sets `waitUntil` to `domcontentloaded` by hand, so its navigation finishes when the DOMContentLoaded event fires. A new test that leaves it out falls back to the default `load`, and the suite drifts back to waiting on every image and script, one test at a time.

<AntiPattern.Fix>

Move the rule into the `page` fixture, next to the timing. This `fixtures.ts` is the complete version the later steps build on:

```ts
// fixtures.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  page: async ({ page }, use) => {
    const goto = page.goto.bind(page);
    page.goto = async (url, options) => {
      const start = Date.now();
      try {
        // Suite default; a call that passes its own waitUntil overrides it.
        return await goto(url, { waitUntil: 'domcontentloaded', ...options });
      } finally {
        base.info().annotations.push({
          type: 'goto',
          description: `${Date.now() - start}ms ${url}`,
        });
      }
    };
    await use(page);
  },
});

export { expect } from '@playwright/test';
```

When multiple objects are [spread](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) into one object and properties with identical names are encountered, the property takes the last value assigned. The fixture spreads `options` last, so a synthetic check can still pass its own `waitUntil` of `load`. Every other test gets `domcontentloaded` and a timing annotation without asking for either. Changing the suite's navigation rule is now one edit in one file.

</AntiPattern.Fix>

</AntiPattern>

## Dev server vs production build

Some CI-only timeouts come from the server, not from the test. The `webServer` option launches a local dev server before the tests run, and the [docs](https://playwright.dev/docs/test-webserver) present it as ideal while you write tests during development, or when there is no staging or production URL to test against. Writing tests on your laptop and gating merges in CI are different jobs.

<AntiPattern title="Dev server in CI" id="anti-pattern-dev-server-in-ci">

CI starts the same dev server you run locally. If that server compiles each route the first time it is requested, the first test to visit a route waits for the compile inside its own test timeout. That timeout is 30 seconds by default. We'd expect that test to time out on some runs where nothing in the code changed, while every later test on the route passes.

<AntiPattern.Fix>

Point `webServer` at a production build in CI and keep the dev server for local runs:

```ts
import { defineConfig } from '@playwright/test';

const isCI = !!process.env.CI;

export default defineConfig({
  webServer: {
    // CI tests the production build; local runs keep the dev server.
    command: isCI ? 'npm run build && npm run start' : 'npm run dev',
    url: 'http://localhost:3000',
    // Startup only. In CI that includes the build.
    timeout: 180_000,
  },
});
```

Under `webServer`, `timeout` is how long to wait for the process to start up and be available, in milliseconds. It defaults to 60000. The CI command above builds before it starts, so size the timeout for the build. Once the server is available, a slow request lands inside a test and counts toward that test's 30 seconds.

</AntiPattern.Fix>

</AntiPattern>

## Skipping login navigations

The fastest navigation is the one a test never makes. A test that logs in through the UI opens the login page, submits the form and follows whatever redirects bring it into the app before it reaches the page under test. Tests can [load an existing authenticated state](https://playwright.dev/docs/auth) instead, so they don't each have to log in, which the docs say speeds up test execution.

Log in once in a setup test and save the state. Our [guide to auth state at scale](/posts/testing-authentication-with-playwright-the-complete-guide#authentication-state-architecture-at-scale) covers per-worker accounts and logging in through the API; the basic version looks like this:

```ts
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('log in once', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
```

Then have the test project load that state and depend on the setup project:

```ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: '**/auth.setup.ts' },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});
```

With the session loaded, go straight to the deep URL the test needs instead of the home page and a walk through the menu:

```ts
import { test, expect } from './fixtures';

test('user can update the billing address', async ({ page }) => {
  await page.goto('http://localhost:3000/settings/billing');
  await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
});
```

## Blocking third-party scripts

A test that still waits for `load` waits for every analytics tag, chat widget and ad script the page pulls in, because the load event fires only once scripts, iframes and images have all loaded. [`page.route()`](https://playwright.dev/docs/api/class-page) can abort those requests so they stop holding the navigation back.

<AntiPattern title="One catch-all route" id="anti-pattern-one-catch-all-route">

A single `**/*` route that inspects each request's hostname and aborts the third-party ones looks tidy. Once routing is enabled, every request matching the pattern stalls until it is [continued, fulfilled or aborted](/posts/the-playwright-network-mocking-playbook#route-handler-mechanics). Glob `**` matches any characters including `/`.

```ts
// Anti-pattern: every request the page makes now stalls in this handler
import { test, expect } from '@playwright/test';

test('checkout page loads', async ({ page }) => {
  await page.route('**/*', (route) => {
    const host = new URL(route.request().url()).hostname;
    if (host.endsWith('analytics.example.net')) return route.abort();
    return route.continue();
  });
  await page.goto('http://localhost:3000/checkout');
  await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible();
});
```

<AntiPattern.Fix>

Route only the third-party hosts, abort them, and do it in the shared fixture so every test gets it:

```ts
// fixtures.ts
import { test as base } from '@playwright/test';

// Third-party hosts only: a matching request stalls until it is handled.
const blockedPatterns = [
  'https://analytics.example.net/**',
  'https://chat-widget.example.org/**',
  'https://ads.example.com/**',
];

export const test = base.extend({
  page: async ({ page }, use) => {
    for (const pattern of blockedPatterns) {
      await page.route(pattern, (route) => route.abort());
    }

    const goto = page.goto.bind(page);
    page.goto = async (url, options) => {
      const start = Date.now();
      try {
        return await goto(url, { waitUntil: 'domcontentloaded', ...options });
      } finally {
        base.info().annotations.push({
          type: 'goto',
          description: `${Date.now() - start}ms ${url}`,
        });
      }
    };
    await use(page);
  },
});

export { expect } from '@playwright/test';
```

</AntiPattern.Fix>

</AntiPattern>

### Cache cost

Enabling routing disables the HTTP cache, however narrow the pattern. Rerun the suite with the timing annotations and compare before and after: keep the blocking only if the navigations got faster.

Each test runs in its own browser context: Playwright creates a new one every time a test runs. A request to reuse the static files an earlier test had downloaded got a maintainer's reply that the disk cache isn't shared between isolated contexts, and the issue is still open ([#22865](https://github.com/microsoft/playwright/issues/22865)).

Don't block the third-party script a test exists to check. If the product depends on a support widget, the test for that widget runs without the route.

## Navigation timeouts

By default, a slow `goto` has no navigation timeout to hit. The `timeout` of `page.goto()` defaults to 0, meaning no timeout, and so does the `navigationTimeout` test option. The limit a stuck navigation runs into is the [test timeout](https://playwright.dev/docs/test-timeouts), 30 seconds by default, so a slow page fails as a test timeout (our [checklist for debugging Playwright timeouts](/posts/debugging-playwright-timeouts) works through that error from the other end).

<AntiPattern title="A bigger test timeout" id="anti-pattern-a-bigger-test-timeout">

The suite starts timing out on slow pages, so the global test timeout goes up until they pass. CI gets slower, the slow page stays slow, and the timing annotations that would have named it go unread because everything is green.

```ts
// Anti-pattern: slow navigations now pass, and every slow second stays in CI
import { defineConfig } from '@playwright/test';

export default defineConfig({
  timeout: 120_000,
});
```

<AntiPattern.Fix>

Set `navigationTimeout` below the test timeout. The option changes the default timeout of `page.goto()`, so a stuck page fails at the `goto` that caused it:

```ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    // Below the 30-second test timeout.
    navigationTimeout: 15_000,
  },
});
```

For one page you know is slow, pass `timeout` on that single `goto` instead of raising anything suite-wide:

```ts
import { test, expect } from './fixtures';

test('annual report renders', async ({ page }) => {
  // Known slow page: the report is built on request.
  await page.goto('http://localhost:3000/reports/annual', { timeout: 25_000 });
  await expect(page.getByRole('heading', { name: 'Annual report' })).toBeVisible();
});
```

The same default can also be set from code with `page.setDefaultNavigationTimeout()` or `browserContext.setDefaultNavigationTimeout()`.

</AntiPattern.Fix>

</AntiPattern>

## CI-only navigation gotchas

Some navigation failures only look like readiness problems. `page.goto()` doesn't throw when the server returns a valid HTTP status code, including 404 and 500. Because `page.goto()` does not throw on a 404 or 500, the test carries on past the navigation and fails later, at a locator that never appears. Assert on the page's own content right after `goto` so a server error fails at the line that navigated.

Time before the test body counts too. Fixture setup and `beforeEach` hooks count toward the test timeout (30 seconds by default), so a `beforeEach` that logs in and opens a slow first page can use up most of a test's time before its first line runs.

| Cause | How to spot it | Fix |
|---|---|---|
| Slow server response | `goto` is slow even with `commit`, which counts as finished when the network response is received and the document started loading | Look at the server, and test a production build in CI |
| Subresources holding back load | Large gap between commit and load timings | `domcontentloaded` with a readiness check, and blocked third-party hosts |
| Dev server compiling routes | The first `goto` to each route is slow, later ones are fast | Production build in CI |
| Login in every test | Each test starts on the login page and follows redirects | Saved authenticated state and deep URLs |
| Error page | `goto` resolves without an error, a locator fails later | Assert on page content right after `goto` |

Before blaming `goto` for hangs that recur at a regular interval in a long run, check what failed just before each one. Playwright Test discards the worker process along with the browser after any failed test and starts a new one. In one WebKit report ([#42385](https://github.com/microsoft/playwright/issues/42385)), a goto hung roughly every 65 navigations. The reporter later traced it to macOS display sleep, and a contributor said it was already fixed in WebKit 2355 and asked them to try Playwright 1.63. The reporter confirmed the fix.

## Tracking navigation time across runs

The timing fixture measures `goto` and nothing after it. The fixture's navigation counts as finished when the `DOMContentLoaded` event fires, so a readiness check after it is not in its numbers. One run's annotations can't tell you whether last month's fix held.

[Currents](https://docs.currents.dev/guides/playwright-annotations) defines a metric from an annotation whose `type` is `currents:metric` and whose `description` is a serialized JSON object. The JSON string must contain a `name` and a `value`. Custom metrics appear in the Test Results chart, where a Custom Metric control lets you browse the available metrics and aggregation functions. The fixture below adds that metric next to the existing annotation on every navigation:

```ts
// fixtures.ts
import { test as base } from '@playwright/test';

const blockedPatterns = [
  'https://analytics.example.net/**',
  'https://chat-widget.example.org/**',
  'https://ads.example.com/**',
];

export const test = base.extend({
  page: async ({ page }, use) => {
    for (const pattern of blockedPatterns) {
      await page.route(pattern, (route) => route.abort());
    }

    const goto = page.goto.bind(page);
    page.goto = async (url, options) => {
      const start = Date.now();
      try {
        return await goto(url, { waitUntil: 'domcontentloaded', ...options });
      } finally {
        const ms = Date.now() - start;
        base.info().annotations.push({ type: 'goto', description: `${ms}ms ${url}` });
        // The metric's description must be a serialized JSON object.
        base.info().annotations.push({
          type: 'currents:metric',
          description: JSON.stringify({ name: 'goto_ms', value: ms }),
        });
      }
    };
    await use(page);
  },
});

export { expect } from '@playwright/test';
```

In Currents' Test Explorer, the Slowest preset lists the tests with the highest average duration. Duration Impact measures how much total time a test contributes to the suite's runtime, from both how long it takes per run and how often it runs. Duration Trend compares a test's average duration across two equal periods, such as the last 30 days against the previous 30 days, and a negative value means the test is running faster.

Wherever the numbers end up, keep the order: time every `goto`, fix the slowest pages first, and keep a change only when the timings drop. A raised timeout never passes that test.

## Frequently asked questions


### Why is page.goto slow in Playwright?

By default, `page.goto()` waits for the load event, which fires only once the whole page has loaded, including stylesheets, scripts, iframes and images. A slow commit points at the server or the network, and a long gap between commit and load points at what the page pulls in afterwards: third-party scripts, fonts and images. The split is explained in [What page.goto waits for](#what-pagegoto-waits-for).


### Which waitUntil should I use with page.goto?

Use `domcontentloaded` as the suite default. Use `commit` only when a readiness check follows it, and keep `load` when the test exists to prove that the page's subresources load, as [Choosing a waitUntil](#choosing-a-waituntil) explains.


### Should I use networkidle in Playwright tests?

No. With `networkidle`, the navigation is finished only when there have been no network connections for at least 500 ms, and the docs mark it as discouraged. The docs say not to use it for testing and to rely on web assertions to assess readiness instead, as [the networkidle anti-pattern](#anti-pattern-networkidle-as-ready) shows.


### How do I wait for hydration after page.goto?

Have the app keep all interactive controls disabled until hydration finishes and the page is fully functional. Then assert that the control the test will use is enabled: with the controls disabled until after hydration, an enabled button means the page is fully functional. The full example is in [Hydration readiness checks](#hydration-readiness-checks).


### How do I set a default waitUntil for every page.goto?

Move the rule into the `page` fixture, next to the timing. The code is in [A shared goto fixture](#a-shared-goto-fixture).


### What is the default navigation timeout for page.goto?

The `timeout` of `page.goto()` defaults to 0, meaning no timeout, and so does the `navigationTimeout` test option. The limit a stuck navigation runs into is the test timeout, 30 seconds by default.


### Why does page.goto time out only in CI?

If CI starts a dev server that compiles each route the first time it is requested, the first test to visit a route waits for the compile inside its own test timeout. Point `webServer` at a production build in CI and keep the dev server for local runs, as [Dev server vs production build](#dev-server-vs-production-build) explains.


### Does page.goto throw on a 404 or 500?

No. `page.goto()` doesn't throw when the server returns a valid HTTP status code, including 404 and 500.




---

## About the author

**Walter Galvão**, Software Engineer. Walter Galvão is a software engineer at Currents. He writes about Playwright in practice: debugging flaky tests, structuring suites for CI, and the engineering workflows that keep large test suites reliable.

LinkedIn: https://www.linkedin.com/in/waltergalvao/
