#15486·puppeteer

[Bug]: page.goto() ignores its own timeout and never settles after a second navigation request

Author: NoamGaashCreated Sep 23, 2026Updated Sep 24, 2026
LabelsconfirmedP2

Minimal, reproducible example

typescript
import http from 'node:http';
import puppeteer from 'puppeteer';

// '/start' loads fine and then navigates again from its own load handler.
// '/never' is accepted and never answered.
const server = http.createServer((req, res) => {
  if (req.url === '/start') {
    res.writeHead(200, {'content-type': 'text/html'});
    res.end(`<html><body onload="location.href='/never'">ok</body></html>`);
  }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const {port} = server.address() as {port: number};

const browser = await puppeteer.launch({args: ['--no-sandbox']}); // container
const page = await browser.newPage();

const startedAt = Date.now();
setTimeout(() => {
  console.error(`still pending after ${Date.now() - startedAt}ms, timeout was 3000ms`);
  process.exit(1);
}, 15_000).unref();

await page.goto(`http://127.0.0.1:${port}/start`, {timeout: 3000});

console.log(`settled after ${Date.now() - startedAt}ms`);
await browser.close();
server.close();

Background

Hit in a test suite. A page.goto() that never settles is invisible from the outside — no error, no stack, nothing to grep for; the process simply stops making progress.

Reproduced on Debian 12 (bookworm) x86_64 in a Docker container, kernel 7.0.0-31-generic, both headless and headful (headless: false under xvfb-run), on the bundled Chrome of each version — 138.0.7204.49 with puppeteer 24.11.1 and 153.0.8010.36 with 25.11.0.

Expectation

TimeoutError: Navigation timeout of 3000 ms exceeded after ~3s.

GoToOptions extends WaitForOptions, whose timeout is documented as "Maximum wait time in milliseconds. Pass 0 to disable the timeout."

Reality

still pending after 15007ms, timeout was 3000ms

The promise never settles — the 15s cap above is just where I stop waiting.

page.waitForNavigation() on the same server, with the same 3000ms, behaves correctly:

puppeteer 24.11.1
  page.goto(timeout:3000)              -> still pending  15008ms
  page.waitForNavigation(timeout:3000) -> TimeoutError     3002ms
puppeteer 25.11.0
  page.goto(timeout:3000)              -> still pending  15006ms
  page.waitForNavigation(timeout:3000) -> TimeoutError     3001ms

Where it comes from

packages/puppeteer-core/src/cdp/Frame.ts — goto ends on a bare await, waitForNavigation races the same value against the termination promise:

typescript
// goto
return await watcher.navigationResponse();

// waitForNavigation
const result = await Deferred.race<Error | HTTPResponse | null | undefined>([
  watcher.terminationPromise(),
  watcher.navigationResponse(),
]);

packages/puppeteer-core/src/cdp/LifecycleWatcher.ts — the awaited deferred carries no timeout of its own, and is replaced on every navigation request reported for the frame:

typescript
#onRequest(request: HTTPRequest): void {
  if (request.frame() !== this.#frame || !request.isNavigationRequest()) return;
  this.#navigationRequest = request;
  this.#navigationResponseReceived?.resolve();
  this.#navigationResponseReceived = Deferred.create();   // no timeout
  if (request.response() !== null) this.#navigationResponseReceived?.resolve();
}

So once the document's lifecycle completes, a further navigation request replaces #navigationResponseReceived with a fresh deferred. If that request never gets a response or a failure, goto waits on it forever, and terminationPromise() — the one carrying the navigation timeout — is no longer in the race.

Giving goto the same guard waitForNavigation already has looks like it would be enough:

typescript
return await Deferred.race([watcher.terminationPromise(), watcher.navigationResponse()]);

Puppeteer version

25.11.0 (also reproduces on 24.11.1)

Node version

22.23.2

Package manager

npm

Package manager version

10.9.8

Operating system

Linux