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 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 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:

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). 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.

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.

Fix

Override the built-in page fixture so every goto records its own duration. Overriding built-in 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:

// 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.

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:

  • 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.

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.

// 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();
});

Fix

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

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();
});

Hydration readiness checks

Stopping early trades slow tests for a different risk: clicking before the page can respond. The Playwright navigation docs 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.

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.

// 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();
});

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:

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.

Readiness signals

What the test needsCheck
A control it will usetoBeEnabled() on that control, with the app keeping it disabled until hydration
Rendered contentA web assertion on that content
One element to act onThe action itself, which waits for its target to become actionable
The page as a wholeNo such signal exists, so pick one of the rows above
A fixed sleepOnly 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.

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.

Fix

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

// 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 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.

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 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.

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.

Fix

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

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.

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 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 covers per-worker accounts and logging in through the API; the basic version looks like this:

// 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:

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:

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() can abort those requests so they stop holding the navigation back.

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. Glob ** matches any characters including /.

// 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();
});

Fix

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

// 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';

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).

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.

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, 30 seconds by default, so a slow page fails as a test timeout (our checklist for debugging Playwright timeouts works through that error from the other end).

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.

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

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

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:

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:

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().

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.

CauseHow to spot itFix
Slow server responsegoto is slow even with commit, which counts as finished when the network response is received and the document started loadingLook at the server, and test a production build in CI
Subresources holding back loadLarge gap between commit and load timingsdomcontentloaded with a readiness check, and blocked third-party hosts
Dev server compiling routesThe first goto to each route is slow, later ones are fastProduction build in CI
Login in every testEach test starts on the login page and follows redirectsSaved authenticated state and deep URLs
Error pagegoto resolves without an error, a locator fails laterAssert 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), 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 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:

// 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.

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 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 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.

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.

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 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.