ScreenshotNeo

BlogHow-to

How to Wait for page.goto() in a Puppeteer Loop

Await page.goto() in a sequential loop, choose the right waitUntil condition, and handle selectors, clicks, timeouts, and failures reliably.

By the ScreenshotNeo team1 October 20264 min read

Await page.goto(url, options) inside a sequential for...of loop. Puppeteer waits for the lifecycle event selected by waitUntil; the documented default is load. Choose domcontentloaded, load, networkidle0, or networkidle2 based on what your next operation needs. For client-rendered pages, also wait for the element or application state that proves the content is ready.

import puppeteer from 'puppeteer';

const urls = ['https://example.com', 'https://example.org'];
const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();

for (const url of urls) {
  await page.goto(url, {waitUntil: 'domcontentloaded', timeout: 30000});
  await page.waitForSelector('body', {visible: true});
  console.log(url, await page.title());
}

await browser.close();

What page.goto() waits for

page.goto() is itself an awaited navigation operation. See the Puppeteer Page.goto API and WaitForOptions.

Condition Use it when Trade-off
domcontentloaded Parsed HTML is enough. Client data may not be rendered.
load The page load event is your boundary. This is the default. It may wait for assets your task does not need.
networkidle0 Zero active connections is meaningful. Long polling or streams can prevent completion.
networkidle2 At most two active connections is a useful approximation. Network quiet does not prove application data is ready.

Network idle is not a universal readiness signal. A selector or locator representing the required state is often more reliable.

Sequential loops

Use for...of or a classic for. Avoid urls.forEach(async ...); forEach does not await returned promises.

const results = [];
for (const url of urls) {
  try {
    const response = await page.goto(url, {waitUntil: 'load', timeout: 45000});
    results.push({url, ok: true, status: response?.status() ?? null});
  } catch (error) {
    results.push({url, ok: false, error: error.message});
  }
}
console.table(results);

Continuing after a failed URL is application policy. Record the URL, wait condition, timeout, and error so the item can be retried.

Wait for the content you need

Wait for a selector

for (const url of urls) {
  await page.goto(url, {waitUntil: 'domcontentloaded'});
  await page.waitForSelector('[data-ready="true"]', {visible: true, timeout: 30000});
  const text = await page.$eval('[data-ready="true"]', el => el.textContent);
  console.log({url, text});
}

waitForSelector resolves when a matching element appears. visible: true also requires visibility, and the documented default timeout is 30 seconds. See the waitForSelector API.

Use a locator for interaction

Puppeteer locators wait for presence and interaction conditions such as visibility, enabled state, and stable layout. See the page interactions guide.

await page.goto(url, {waitUntil: 'domcontentloaded'});
await page.locator('button[data-action="continue"]').click();

Wait for application state

await page.goto(url, {waitUntil: 'domcontentloaded'});
await page.waitForFunction(() => document.querySelector('#results')?.dataset.state === 'ready', {timeout: 30000});

Prefer a condition tied to your app. Fixed delays are less predictable:

await new Promise(resolve => setTimeout(resolve, 500));

Install the navigation wait before clicking to avoid a race.

const [response] = await Promise.all([
  page.waitForNavigation({waitUntil: 'domcontentloaded', timeout: 30000}),
  page.click('a.next'),
]);
console.log(response?.status() ?? 'no document response');

waitForNavigation() can resolve to null for hash or History API navigation. See the waitForNavigation API.

Timeouts and failure handling

page.setDefaultNavigationTimeout(45000);
page.setDefaultTimeout(30000);
try {
  await page.goto(url, {waitUntil: 'networkidle2'});
} catch (error) {
  console.error({url, name: error.name, message: error.message});
}

Per-call timeout overrides the default. A timeout of zero disables the limit; use it only with an external cancellation mechanism.

Common errors and fixes

Symptom Cause Fix
Next URL starts too early Missing await or use of forEach(async ...). Use for...of and await navigation and readiness waits.
Navigation timeout Slow server, blocked request, or strict lifecycle condition. Log the URL and condition; tune timeout or use a selector.
networkidle0 never resolves Long polling, WebSockets, or analytics. Use domcontentloaded or load plus an app-specific selector.
Selector timeout Wrong selector, iframe, delayed data, or an error page. Verify the selector, target the correct frame, and inspect the rendered state.
waitForNavigation() returns null History API or hash navigation. Check URL or DOM state instead of dereferencing the response.

Performance and reliability checklist

  • Reuse a browser and page when safe; browser startup is expensive.
  • Use the weakest wait boundary that satisfies the next operation.
  • Add concurrency only with separate pages and a controlled limit.
  • Record timings, status codes, and errors per URL.
  • Retry transient network failures with backoff, but do not retry selector bugs indefinitely.
  • Close resources in finally.
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  for (const url of urls) {
    const started = Date.now();
    try {
      await page.goto(url, {waitUntil: 'domcontentloaded', timeout: 30000});
      await page.waitForSelector('#results', {timeout: 10000});
      console.log({url, ms: Date.now() - started, ok: true});
    } catch (error) {
      console.error({url, ms: Date.now() - started, ok: false, error: error.message});
    }
  }
} finally {
  await browser.close();
}

Or skip the browser setup

If you need an image or PDF rather than browser control, ScreenshotNeo provides one GET request. Read the API documentation for options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'}, timeout=90)
open('shot.webp', 'wb').write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Before capture, cookie and consent banners are accepted and more than 60 known consent platforms, newsletter popups, and chat widgets are removed; each step can be disabled. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. MCP tools include take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots monthly with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account.

FAQ

Does page.goto() wait automatically?

Yes. It waits for the configured navigation condition, but it cannot know which application element means ready.

Should I always use networkidle0?

No. Persistent connections can prevent it from resolving. Match the condition to the site.

Can I call waitForNavigation() after goto()?

That waits for a later navigation. Pair it with the action that triggers navigation.

How should I process hundreds of URLs?

Start sequentially, record outcomes, then add bounded concurrency with separate pages if needed.