← all articles

How to fix Playwright's page.goto timing out with no load event ever firing

The error looks like this: page.goto: Timeout 30000ms exceeded. with waiting until "load" in the call log underneath. The page often renders fine in a headed browser on your laptop. On the agent host it just sits there, and 30 seconds later the run dies.

This is the question in Playwright issue 12182, and the short answer is that goto is waiting for an event your page will never deliver. Usually that event is load, which the browser only fires after every image, stylesheet, script and iframe has finished (MDN). One hung subresource is enough to hold it open indefinitely. The other suspects are networkidle on a page that polls, a redirect loop, and a route handler you wrote yourself.

This is for people running Playwright agents against sites they own or are allowed to automate, where a stalled navigation eats a queue slot. By the end you’ll have a logger that names the request holding load open and a safeGoto helper that returns once the page is usable. Chromium only: I haven’t checked Firefox or WebKit, and the thread may hold details about the reporter’s setup that my answer doesn’t cover.

What you need

  • Playwright for Node with Chromium installed (npm i playwright, then npx playwright install chromium). The Python API has the same calls in snake_case.
  • A URL that reproduces the stall, on a site you own or have permission to automate, within its terms.
  • curl on the machine the agent runs on, not your laptop.
  • A proxy account only if your agent already uses one.

Step by step

1. Rule out the network path

Run curl from the agent host and time it:

curl -sL -o /dev/null --max-redirs 10 --max-time 30 \
  -w "status=%{http_code} redirects=%{num_redirects} ttfb=%{time_starttransfer} total=%{time_total}\n" \
  "https://your-site.example/page"

Expected output is one line such as status=200 redirects=1 ttfb=0.4 total=0.7. If that comes back fast, the network and the main document are fine and the stall lives in the browser’s lifecycle.

If it breaks: exit code 47 means curl hit the redirect cap, so jump to step 6. Exit code 28 means the request timed out, a network or proxy problem, not a Playwright one. If the agent reaches the site through a proxy and only that path fails, my write-up on ERR_TUNNEL_CONNECTION_FAILED with a cloud browser agent covers that error. And if it’s your own Singapore-facing site and you want to see what a real local mobile connection gets, Singapore Mobile Proxy sells real Singapore mobile IPs with sticky sessions. Singapore only, so skip it if your users are elsewhere.

2. Log the lifecycle and the pending requests

import { Page, Request } from 'playwright';

export function trace(page: Page) {
  const t0 = Date.now();
  const pending = new Set<Request>();
  const log = (m: string) => console.log(`${Date.now() - t0}ms ${m}`);
  page.on('domcontentloaded', () => log('domcontentloaded'));
  page.on('load', () => log('load'));
  page.on('request', r => pending.add(r));
  page.on('requestfinished', r => pending.delete(r));
  page.on('requestfailed', r => {
    pending.delete(r);
    log(`failed ${r.url()} ${r.failure()?.errorText}`);
  });
  page.on('framenavigated', f => f === page.mainFrame() && log(`navigated ${f.url()}`));
  return () => [...pending].map(r => r.url());
}

Then drive it with a commit-only goto so the logger gets time to run:

const pendingUrls = trace(page);
await page.goto(url, { waitUntil: 'commit', timeout: 15_000 });
await page.waitForTimeout(20_000);
console.log('still pending:', pendingUrls());

Expected output: timestamped navigated and domcontentloaded lines, then either a load line or a still pending list naming what’s holding it open.

If it breaks: no domcontentloaded either means the main document never finishes, so check step 1 again for a server or proxy holding the response open. An empty pending list with no load points at a client-side redirect, covered in step 6.

3. Read the pending list

The culprits I’d expect:

  • a third-party host (ads, analytics, a chat widget, a hosted stylesheet) that never answers. If your egress firewall drops packets to it rather than rejecting them, the connection hangs until the OS gives up, which is far longer than 30 seconds.
  • one of the site’s own images or iframes pointing at a dead hostname.
  • an endless response embedded as an image or iframe, like a camera stream.

Expected output: a short list of hosts, not hundreds.

If it breaks: hundreds of pending URLs means the page is just heavy, so go to step 5 and stop fetching images and media.

4. Pick a waitUntil that matches the job

The page.goto docs list four values. As of September 2026 the default is load with a 30 second timeout; check that page if your Playwright version is old.

  • load: waits for every subresource, so any hung request stalls you.
  • domcontentloaded: the HTML is parsed and deferred scripts have run. Images and iframes aren’t awaited.
  • networkidle: no network connections for 500 ms. The docs discourage it, and pages with polling, beacons or chat widgets may never get there.
  • commit: the response arrived and the document started loading. Cheapest option, but you wait for readiness yourself.

My default for agent work is domcontentloaded, then wait for the one element the agent needs. I switch to commit only when the HTML streams slowly or ends late.

Expected output: goto resolves at about the speed of the HTML response and hands back a Response. Check response?.status() yourself, because goto doesn’t throw on a 404 or 500.

If it breaks: goto still times out on domcontentloaded, so the document isn’t finishing. Use commit and go back to step 2.

5. Fix your route handlers and drop dead weight

A self-inflicted cause: a page.route handler with a branch that never calls continue, fulfill or abort. Every path has to end in one of them:

await context.route('**/*', route =>
  ['image', 'font', 'media'].includes(route.request().resourceType())
    ? route.abort()
    : route.continue()
);

Skipping images and fonts also removes a whole class of hung CDN requests. Leave images out of the block list if your agent reads screenshots, and compare a blocked run against an unblocked one before you trust it.

Expected output: the pending list shrinks or empties, and load appears.

If it breaks: one third-party host still hangs. Add its hostname to the abort branch, provided the page doesn’t need it.

6. Trace redirects, server side and client side

page.on('response', r => {
  const s = r.status();
  if (s >= 300 && s < 400) console.log(s, r.url(), '->', r.headers()['location']);
});

HTTP loops fail fast. Browsers stop after 20 hops (the Fetch standard sets that limit) and Chromium throws net::ERR_TOO_MANY_REDIRECTS. So if you got a timeout, suspect a client-side redirect: a JavaScript location assignment or a meta refresh that restarts navigation before load fires. Playwright sometimes reports that as Navigation to "X" is interrupted by another navigation to "Y", and repeated navigated lines from step 2 confirm it.

The causes I’d check first:

  • a cookie the site sets and then redirects on, which a fresh or cookie-blocked context never keeps.
  • locale redirects, like /en to /sg and back, when the context locale doesn’t match what the site expects. Set locale and timezoneId to what your real users have.
  • a CDN that terminates TLS while the origin redirects to https again, for example Cloudflare’s Flexible SSL mode with an https redirect at the origin.
  • www and non-www pointing at each other.

Expected output: either a chain that ends, or 3xx lines bouncing A to B to A.

If it breaks: on your own site, fix the redirect rule at the source. On someone else’s, if the loop survives correct cookies and locale, stop and ask them. I don’t script around it.

7. Put it together

const context = await browser.newContext({ locale: 'en-SG', timezoneId: 'Asia/Singapore' });
context.setDefaultNavigationTimeout(20_000);
context.setDefaultTimeout(10_000);

export async function safeGoto(page: Page, url: string, ready: string) {
  const res = await page.goto(url, { waitUntil: 'domcontentloaded' });
  await page.locator(ready).first().waitFor({ state: 'visible' });
  await page.waitForLoadState('load', { timeout: 3_000 }).catch(() => {});
  return res;
}

That’s a 20 second navigation ceiling, a 10 second readiness wait, and a best-effort 3 seconds for load that can’t fail the run. It’s my direct answer to issue 12182: stop making load the gate. ready is a selector like main or [data-testid="results"]. Swap en-SG for your users’ locale, and register the route handler from step 5 on the same context.

Expected output: safeGoto returns about when the HTML and your ready element show up. A page whose load event never fires costs you at most 3 seconds on top.

If it breaks: the ready locator times out although the page looks fine. The selector is wrong, or the element lives inside an iframe, in which case use page.frameLocator.

8. Retry in a fresh context and keep a trace

Retry once, in a new context rather than on the same page. Cookies and redirect state from the failed attempt poison the retry. Record a trace per attempt and keep it only on failure:

await context.tracing.start({ screenshots: true, snapshots: true });
// on failure, in a finally block:
await context.tracing.stop({ path: `trace-${Date.now()}.zip` });

On success call tracing.stop() with no path and the trace is discarded. Open a saved one with npx playwright show-trace trace-<timestamp>.zip. It’s the small version of the idea in my piece on observing AI agents through traces, replays and cost.

Expected output: a zip you can open after the fact and read the network tab, instead of reproducing the stall.

If it breaks: no zip means tracing never stopped cleanly, so put the stop call in finally and close the context after it.

Common pitfalls

  • Raising the timeout. A 120 second timeout or timeout: 0 doesn’t bring a load event back. It just holds a browser slot longer per stall.
  • Making networkidle the default because it sounds like the careful choice. On a page with a polling widget it never resolves.
  • Testing on a laptop and deploying to a server. Egress rules, DNS and firewalls differ, so a hang can be your firewall dropping packets to a tracker host.
  • Retrying on the same page or context. The bad cookie or half-finished redirect state comes along.
  • Treating a resolved goto as success. It resolves on a 404 or 500 too, so the agent reads an error page as content.

Scaling this

At 10x this is hygiene. Use safeGoto everywhere, run trace() only on retries, keep tracing zips for failures, and write down the hostnames you abort.

At 100x the timeout becomes your throughput ceiling. Cutting 30 seconds to 20 hands back 10 seconds of slot time per stalled page. Count timeouts per host, not just overall, so one site with a bad third-party script doesn’t hide in the average. If slow responses only show up after you raise parallel pages against one site, that can be the site rate limiting you, and the fix is fewer pages per host. My note on how IP reputation is earned and lost is the background there.

At 1000x, pin the Chromium build so lifecycle behaviour doesn’t shift between deploys. My comparison of Chrome for Testing and open-source Chromium for a Playwright agent fleet is where I’d start. Add a per-host circuit breaker that stops sending pages to a host after repeated stalls and retries later. If any traffic goes through proxies, tag those failures separately, otherwise a bad proxy exit looks like a site problem. And store traces centrally.

Where to go next

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-09-22.

free download
Why did my agent get blocked? A triage checklist

The checks we run, in order, when a browser or phone agent starts failing: network, fingerprint, behaviour, account. Leave your email and we will also tell you when we publish a new field note, a few times a month at most.

from the team behind this site
A Singapore mobile IP for browser agents

Singapore Mobile Proxy runs real mobile IPs on SingTel, StarHub and M1, with sticky sessions so one task keeps one IP. Singapore only: a fit for SEA or location-agnostic work, the wrong tool if you need a US IP.

see plans →
from the team behind this site
A real Android phone for phone-use agents

cloudf.one hosts real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP. For agents that need an actual device and a stable carrier identity.

get a phone →
read on
More from The Agent Ops Report

Blocks, sessions, retries, traces, cost per task and phone-use agents. Browse all articles →