Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

If Puppeteer appears to navigate to the same URL repeatedly, first determine what is repeating: your Node.js code may call page.goto() more than once, one call may follow an HTTP redirect chain, page JavaScript may trigger client-side navigations, or request interception may be stalling or aborting a navigation. These cases look similar in logs but require different fixes.

Trace the caller, main-frame requests, redirects, URL changes, and interception decisions separately. Do not start by increasing the timeout: waitUntil changes when Puppeteer considers navigation complete; it does not stop a redirect loop or page code that keeps navigating.

What page.goto() actually does

page.goto(url, options) starts a frame navigation and returns a promise for the main resource response. If the server sends multiple redirects, the promise resolves with the response for the last redirect (Puppeteer Page.goto API). Navigation to about:blank, or to the same URL with only a different hash, returns null.

The URL you see is a separate signal. page.url() reports the page’s current URL, while page.on('framenavigated') reports frame URL transitions. History API calls such as history.pushState() also count as navigation for waitForNavigation(), even though they do not fetch a new document (Page class API). Therefore, a changing URL does not necessarily mean a new HTTP document request.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

First classify the repetition

Repeated calls from your process

A loop, retry wrapper, queue consumer, test hook, or two code paths may invoke goto() repeatedly. If your counter increases once per call, the bug is in application control flow rather than the website.

One call, several HTTP requests

A single navigation can produce a chain such as / → /login → /. Record every navigation request and response to distinguish normal redirects from a loop.

Page-side navigation

Scripts can assign location, submit a form, call history.pushState(), or use a client router. These transitions can happen after goto() has started or resolved.

A wait that appears hung

waitForNavigation() can wait for an action-triggered transition, while an intercepted request can remain unresolved indefinitely. A stuck wait is not proof that goto() is repeatedly called.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Instrument the call site and main frame

Start with a narrow trace. Adapt event names and logging to the Puppeteer version installed in your project; this is diagnostic instrumentation, not a guaranteed fix.

let gotoCount = 0;

page.on('request', request => {
  if (request.isNavigationRequest()) {
    console.log('navigation request', {
      mainFrame: request.frame() === page.mainFrame(),
      url: request.url(),
      method: request.method()
    });
  }
});

page.on('framenavigated', frame => {
  if (frame === page.mainFrame()) {
    console.log('main frame navigated', frame.url());
  }
});

const requestedUrl = inputUrl;
console.log('goto start', ++gotoCount, requestedUrl, 'current:', page.url());
const response = await page.goto(requestedUrl, { waitUntil: 'domcontentloaded' });
console.log(
  'goto resolved',
  response?.url() ?? null,
  response?.status() ?? null,
  'current:', page.url()
);

Compare three counts: the number of goto start lines, navigation-request events, and main-frame URL transitions. If the first count is high, search callers and retries. If it is one but the other counts are high, inspect redirects or page code. If requests stop without a resolution, inspect interception.

Log the exact input

Log the string immediately before each call, including query parameters and fragments. A tiny difference such as a trailing slash, URL-encoded value, or changing token can make an apparently identical URL a different request. Also log the current page.url() so you can see whether your code is navigating back to an earlier state.

Capture errors without hiding them

try {
  const response = await page.goto(requestedUrl, {
    waitUntil: 'domcontentloaded',
    timeout: 30_000
  });
  console.log('status', response?.status() ?? null);
} catch (error) {
  console.error('goto failed', {
    name: error.name,
    message: error.message,
    current: page.url()
  });
  throw error;
}

Do not catch an error, immediately retry the same URL, and then interpret the retry as Puppeteer’s spontaneous navigation. Add a bounded retry policy with a reason and attempt number if retries are intentional.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Separate redirects from client-side navigation

HTTP redirect chains

Use request and response logging to identify status codes such as 301, 302, 307, and 308. A server or proxy may redirect HTTP to HTTPS, add or remove a slash, enforce a locale, or send unauthenticated users to a login page. If two endpoints redirect to each other, Puppeteer will keep following the chain until the browser’s navigation limits or timeout are reached.

Inspect the final response URL and status, then reproduce the URL with a normal browser or an HTTP client. Check proxy rules, authentication cookies, canonical-host settings, and environment-specific base URLs. Fix the redirect source; changing waitUntil only changes waiting semantics.

Client-side redirects and routers

After the document loads, page JavaScript may navigate to the same path or repeatedly replace it. Add a framenavigated listener and, when permitted for debugging, inspect the page’s console output:

page.on('console', message => {
  console.log('page console', message.type(), message.text());
});

Look for timers, authentication guards, service-worker logic, route normalization, or code that assigns window.location on every render. A closed Puppeteer issue reported a hang after a handler aborted a main-frame client-side redirect; it was opened on October 27, 2022 and discussed versions 16.1.1 through 19.2.0 (issue #9175). That report is a historical diagnostic lead, not evidence of a universal defect in current Puppeteer.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

History API changes

pushState and replaceState can change page.url() without requesting a new document. If your code treats every URL change as a signal to call goto(), it can create a feedback loop: navigation changes the URL, your watcher sees the URL, and the watcher starts another navigation. Track whether a transition generated a network request before issuing a new goto().

Audit request interception

When interception is enabled with page.setRequestInterception(true), every request stalls until it is continued, responded to, aborted, or satisfied from browser cache. The official Request Interception guide warns that each request must reach exactly one resolution path.

Use a synchronous resolution guard

page.on('request', request => {
  if (request.isInterceptResolutionHandled()) return;

  // Apply narrowly scoped rules here.
  request.continue();
});

Multiple listeners can compete. One may abort while another tries to continue, producing an already-handled error or an apparently stalled page. Remove duplicate listeners when reusing a page, and make filtering explicit for document requests, images, analytics, and other resources.

Be careful with asynchronous handlers

The guide notes that an asynchronous wait creates a race: another handler can resolve the request while your handler is awaiting. If asynchronous work is unavoidable, check isInterceptResolutionHandled() again immediately before resolving, and keep that check and the action together synchronously:

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
page.on('request', async request => {
  if (request.isInterceptResolutionHandled()) return;

  const shouldBlock = await decideWhetherToBlock(request.url());

  if (request.isInterceptResolutionHandled()) return;
  if (shouldBlock) request.abort();
  else request.continue();
});

Prefer a synchronous, narrow rule for navigation requests. Blocking a stylesheet or tracker is different from aborting the main-frame request that carries a client-side redirect. Test navigation requests separately from subresources.

Resolve every branch

Code such as if (isAd(request)) request.abort(); followed by no else leaves non-ad requests pending. Make the default action explicit, and ensure exceptions in your handler cannot leave a request unresolved. If an external helper installs its own listener, inspect its behavior before adding another interceptor.

Pair actions with waitForNavigation() safely

Use waitForNavigation() when an action such as a click is expected to cause navigation, not as a general replacement for goto(). Register the wait before triggering the action and await both promises to avoid the race in which navigation finishes before the wait is installed:

const navigation = page.waitForNavigation({ waitUntil: 'domcontentloaded' });
await page.click('a.next');
await navigation;
console.log('arrived at', page.url());

If the click sometimes performs an in-page History API transition and sometimes a full load, define what outcome your test needs and observe both URL and network events. A navigation wait can resolve for a History API URL change even though no new document was fetched.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Choose waitUntil only after tracing activity

waitUntil: 'load', 'domcontentloaded', and network-idle conditions control when Puppeteer resolves a navigation promise. A network-idle condition can remain pending on pages with long-lived connections, polling, or continuously requested resources. It cannot suppress a redirect loop or stop page JavaScript from navigating again. Once logs show the intended navigation is occurring, choose the least restrictive condition that matches your task, and set a timeout appropriate to the page rather than using a larger timeout to conceal a loop.

A practical decision table

Evidence in logs Most likely area Next check
Several “goto start” lines for one job Caller, retry, queue, or test hook Search every call site and log attempt ownership.
One call, repeated 3xx navigation requests HTTP redirect or proxy loop Record status, Location target, cookies, and host normalization.
Few network requests, many frame URL changes Client router or History API Inspect page scripts and prevent URL observers from calling goto recursively.
Requests stop while interception is enabled Unresolved or multiply resolved request Audit every listener and add the synchronous resolution guard.
Action wait hangs or resolves unexpectedly Incorrect wait pairing or event race Install wait first and verify whether the action causes a document load.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Troubleshooting common errors

“Navigation timeout exceeded”

First determine whether requests are looping, the page is intentionally busy, or interception left a request stalled. Log the redirect sequence and unresolved requests. Only then adjust timeout or waitUntil.

“Request is already handled”

Two listeners resolved the same request, or an asynchronous listener resumed after another listener acted. Consolidate handlers, guard every path, and re-check immediately before resolution.

The final URL is the original URL

The server may redirect back after authentication, a cookie may be missing, or client code may normalize the route. Compare response URL, status, cookies, and frame events instead of assuming goto() called itself.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The script works with interception disabled

Enable handlers one at a time. Confirm that every request is continued, responded to, or aborted exactly once, and avoid broad abort rules for main-frame requests.

Repeated navigation starts after a click

Check whether the click handler itself submits twice, whether a framework retries the route, or whether your test calls the action again after a failed wait. Register the wait before the click and log the click attempt.

What to include when asking for help

  • The installed Puppeteer version and browser revision.
  • A minimal script containing the requested URL and the exact goto or action/wait pair.
  • Every request-interception listener, including helper packages.
  • Ordered logs showing caller count, navigation requests, response status and URL, frame navigations, and errors.
  • Whether the behavior changes when interception, extensions, proxies, or authentication are disabled.

This information lets others distinguish a caller loop from a server redirect, page-side navigation, or interception race instead of guessing from a timeout message.

Or skip the browser setup

If your goal is simply to obtain a clean website image or PDF rather than debug a browser workflow, ScreenshotNeo provides a GET-based screenshot API. For example:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the complete parameter reference and options in the ScreenshotNeo documentation. ScreenshotNeo accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account to try it without a card.

Frequently Asked Questions

Does page.goto() call itself when a site redirects?

No. One call can follow multiple server redirects, while page scripts or your own code can initiate additional navigations. Log the caller and navigation events separately.

Should I always use networkidle to fix a hanging navigation?

No. Network-idle is only a completion condition. Diagnose redirects, page-side navigation, and request interception before changing it.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Can a History API change trigger waitForNavigation()?

Yes. Puppeteer treats History API URL changes as navigation for waitForNavigation(), even without a new document request.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.