Skip to content

[Bug]: page.goto never resolves in Firefox (~0.5-1.2% of navigations) while the page is fully loaded (readyState complete, load fired) #42183

Description

@culturaestoica-lab

Summary

In Firefox, roughly 1 in 80–200 navigations leaves page.goto() (or page.reload()) pending until the timeout expires, even though the page is fully loaded and the driver itself observed the lifecycle events. Chromium and WebKit: zero occurrences across the same runs.

The strongest signal we can offer: the stuck page is perfect (new document, readyState: "complete", hydrated, 0 in-flight requests, page.evaluate works throughout), every locator hangs on waiting for navigation to finish, and a superseding page.goto() to the same URL releases it in under 350 ms — while window.stop() and a same-document history.pushState do not. This points at navigation-completion bookkeeping on the driver side rather than at the browser or the page.

Environment

@playwright/test 1.62.0 (Firefox 153.0) and 1.61.1 (Firefox 151.0) — both affected
OS ubuntu-24.04 (GitHub-hosted, 2 vCPU) and Windows 11 (8 cores)
Workers 1
Retries 0
App under test Next.js 16 production server on 127.0.0.1 (localhost only)

Reproducing conditions

A plain Node script (no test runner, attached below) reproduces it when it combines the three things @playwright/test does per test — and stops reproducing if any one is removed:

  1. fresh browser.newContext() per iteration;
  2. page event listeners attached before navigating (request / requestfinished / requestfailed / response / pageerror);
  3. a cycle of 1–3 goto per context, rotating 8 routes.

Measured rates with that harness:

environment target navigations stuck rate
ubuntu-24.04 (2 vCPU), full test suite real Next.js app ~455/run 1–6 per run ≈0.5–1.2 %
Windows 11 (8 cores), harness real Next.js app 1,404 8 0.57 %
Windows 11, hot loop (single page reused) real Next.js app 3,000 0–2 (different mode: old document) ~0

More CPU does not reduce the rate.

State captured at the moment goto is stuck

The harness interrogates the live page when goto times out:

document.readyState ............ "complete"
page.on("load") fired .......... true
page.on("domcontentloaded") .... true
location.pathname .............. the requested route (new document confirmed
                                 via performance.timeOrigin change)
app hydration flag ............. set
in-flight requests ............. 0   (tracked via request/requestfinished/requestfailed)
resource entries with
  responseEnd === 0 ............ 0
requestfailed .................. 0
pageerror ...................... 0

The goto promise never settles regardless, and every subsequent locator hangs with:

- waiting for locator('main')
  - waiting for "http://127.0.0.1:3000/…" navigation to finish...

Not specific to the awaited event

waitUntil result
load hangs
domcontentloaded hangs
commit hangs

commit does not wait for any lifecycle event, which suggests the problem is in navigation handling rather than in lifecycle-event delivery.

What un-sticks the page — measured over 8 stuck navigations

Action on the stuck page Locators usable afterwards
window.stop() 0/8
history.pushState (same-document navigation) 0/8
A superseding page.goto() to the same URL 8/8, resolving in 43–346 ms

page.evaluate works at all times. That a superseding navigation releases the page instantly — while window.stop() does not — suggests the stuck piece is the navigation-completion bookkeeping between Playwright and Juggler, not the renderer.

Reduction attempts (all negative — the trigger seems to need a real-world app)

Same harness in every row. At the real app's rate, each 2,400-navigation row would be expected to produce ~14 events, so each zero is meaningful:

Target Stuck / navigations
Real-world Next.js 16 app (production server) 8 / 1,404
Trivial static pages 0 / 2,400
+ abortable in-flight requests + chunked HTML 0 / 2,400
+ conditional revalidation (ETag/304; Firefox's shared HTTP cache) 0 / 2,400
Vanilla Next 16 (create-next-app, includes next/font) 0 / 2,400
+ next/image (eager+lazy) + strict CSP + dynamic SSG route 0 / 2,400
+ live JS at navigation time (5 s timer, 900 ms tick, continuous rAF, IntersectionObserver) 0 / 2,400

We understand a repro that needs a private app is not ideal. We are happy to run any instrumented build or diagnostic patch against the reproducing app on demand and report back.

Also reaches other APIs

Observed on the same suite: page.reload stuck with waiting for navigation until "load", and waitForSelector never satisfied on a page that had in fact hydrated.

Harness script

Works against any locally served target (SONDA_BASE); reproduces only against the real app so far. The route list below is sanitized (the app is not public yet); everything else is verbatim. Comments are in Spanish; the structure is: fresh context per iteration → attach listeners → 1–3 goto cycle → on timeout, interrogate the live page and try the un-stick ladder, logging JSONL.

p1-load-condiciones-suite.mjs
/**
 * SONDA P1-LOAD · FASE 2 · condiciones fieles a la suite real
 *
 * La fase 1 (bucle caliente, una sola Page) dio 0/3000: esa condición NO
 * reproduce. La suite real crea un CONTEXTO nuevo por prueba y añade cinco
 * listeners de instrumentación antes de navegar. Esta fase imita eso:
 *
 *   iteración = newContext → newPage → listeners → 1..3 goto → close
 *
 * Ante TimeoutError con documento completo, prueba la escalera:
 *   M1 window.stop → M4 pushState → M2 goto superpuesto → M3 Page nueva
 *   (M3 en el MISMO contexto, que es lo que podría hacer navegar.ts)
 */
import { createRequire } from "node:module";
import { appendFileSync, writeFileSync } from "node:fs";

/* Se resuelve contra el playwright del repositorio, esté donde esté el clon. */
const require = createRequire(import.meta.url);
const { firefox, devices } = require("playwright");

const BASE = process.env.SONDA_BASE ?? "http://127.0.0.1:3000";
const RUTAS = [
  "/",
  "/list",
  "/list/article-1",
  "/series/series-1",
  "/about",
  "/newsletter",
  "/newsletter/1",
  "/corrections",
]; /* route list sanitized for this issue - originals are 8 routes of the private app */
const MAX_ITERACIONES = Number(process.env.SONDA_ITERS ?? 900);
const MAX_ANOMALIAS = Number(process.env.SONDA_ANOMALIAS ?? 8);
const MAX_MS = Number(process.env.SONDA_MAX_MS ?? 85 * 60 * 1000);
const GOTO_TIMEOUT = 20_000;
const LOG = process.env.SONDA_LOG ?? "sonda-p1load-2.jsonl";

const linea = (obj) => appendFileSync(LOG, JSON.stringify(obj) + "\n");

async function locatorVivo(page, ms = 5_000) {
  const t0 = Date.now();
  try {
    await page.locator("main, body").first().waitFor({ state: "attached", timeout: ms });
    return { ok: true, ms: Date.now() - t0 };
  } catch (e) {
    return { ok: false, ms: Date.now() - t0, error: String(e?.message ?? e).slice(0, 160) };
  }
}

async function senales(page, rutaEsperada) {
  try {
    const s = await page.evaluate(() => ({
      pathname: location.pathname,
      readyState: document.readyState,
      hidratado: document.documentElement.getAttribute("data-hydrated") === "1",
      mainPresente: Boolean(document.querySelector("main")),
      recursosSinFin: performance
        .getEntriesByType("resource")
        .filter((e) => !(e.responseEnd > 0)).length,
    }));
    return { evaluateVivo: true, coincideRuta: s.pathname === rutaEsperada, ...s };
  } catch (e) {
    return { evaluateVivo: false, error: String(e?.message ?? e).slice(0, 160) };
  }
}

async function main() {
  writeFileSync(LOG, "");
  const t0 = Date.now();
  const browser = await firefox.launch({ headless: true });

  let navs = 0;
  let anomalias = 0;
  let iter = 0;

  while (iter < MAX_ITERACIONES && anomalias < MAX_ANOMALIAS && Date.now() - t0 < MAX_MS) {
    iter++;
    const context = await browser.newContext({ ...devices["Desktop Firefox"] });
    const page = await context.newPage();

    /* instrumentación equivalente a la de navegar.ts: listeners vivos ANTES */
    const enVuelo = new Set();
    page.on("request", (r) => enVuelo.add(r));
    page.on("requestfinished", (r) => enVuelo.delete(r));
    page.on("requestfailed", (r) => enVuelo.delete(r));
    page.on("response", () => undefined);
    page.on("pageerror", () => undefined);

    const cuantas = 1 + (iter % 3); // 1..3 navegaciones por "prueba"
    for (let k = 0; k < cuantas; k++) {
      const ruta = RUTAS[(iter * 3 + k) % RUTAS.length];
      navs++;
      try {
        await page.goto(BASE + ruta, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT });
      } catch (error) {
        const esTimeout = /Timeout .*exceeded|TimeoutError/.test(String(error?.message ?? ""));
        if (!esTimeout) {
          linea({ tipo: "error-no-timeout", iter, nav: navs, ruta, error: String(error?.message).slice(0, 300) });
          continue;
        }
        anomalias++;
        const registro = {
          tipo: "anomalia", n: anomalias, iter, nav: navs, ruta, navDeLaPrueba: k + 1,
          pendientes: enVuelo.size, momento: new Date().toISOString(),
        };
        registro.senales = await senales(page, ruta);
        registro.locatorAntes = await locatorVivo(page);

        await page.evaluate(() => window.stop()).catch(() => undefined);
        registro.m1 = await locatorVivo(page);

        if (!registro.m1.ok) {
          await page
            .evaluate(() => history.pushState(history.state, "", location.href))
            .catch((e) => (registro.m4error = String(e?.message).slice(0, 160)));
          registro.m4 = await locatorVivo(page);
        }

        if (!registro.m1.ok && !(registro.m4 && registro.m4.ok)) {
          try {
            const t = Date.now();
            await page.goto(BASE + ruta, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT });
            registro.m2goto = { ok: true, ms: Date.now() - t };
          } catch (e) {
            registro.m2goto = { ok: false, error: String(e?.message).slice(0, 160) };
          }
          registro.m2 = await locatorVivo(page);
        }

        if (!registro.m1.ok && !(registro.m4 && registro.m4.ok) && !(registro.m2 && registro.m2.ok)) {
          try {
            const nueva = await context.newPage();
            const t = Date.now();
            await nueva.goto(BASE + ruta, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT });
            registro.m3 = await locatorVivo(nueva);
            registro.m3.gotoMs = Date.now() - t;
            await nueva.close();
          } catch (e) {
            registro.m3 = { ok: false, error: String(e?.message).slice(0, 160) };
          }
        }

        linea(registro);
        console.log(
          `⚠ anomalía ${anomalias} · iter ${iter} nav ${navs} (${ruta}) · ` +
          `m1=${registro.m1?.ok} m4=${registro.m4?.ok ?? "-"} m2=${registro.m2?.ok ?? "-"} m3=${registro.m3?.ok ?? "-"}`,
        );
        break; // la "prueba" termina aquí, como en la suite
      }
    }
    await context.close().catch(() => undefined);

    if (iter % 50 === 0) {
      console.log(`… ${iter} iteraciones, ${navs} navegaciones, ${anomalias} anomalías, ${Math.round((Date.now() - t0) / 60000)} min`);
    }
  }

  linea({
    tipo: "resumen",
    iteraciones: iter,
    navegaciones: navs,
    anomalias,
    minutos: Math.round((Date.now() - t0) / 60000),
    playwright: require("playwright/package.json").version,
  });
  console.log(`FIN F2 · ${iter} iteraciones · ${navs} navegaciones · ${anomalias} anomalías · ${Math.round((Date.now() - t0) / 60000)} min`);
  await browser.close();
}

main().catch((e) => {
  linea({ tipo: "crash", error: String(e?.stack ?? e) });
  console.error(e);
  process.exit(1);
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions