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

The reliable fix is to await page.waitForSelector() inside a sequential for...of loop, immediately before the action or extraction that needs the element. Also verify that the selector represents the next state you need. Puppeteer returns immediately when a matching element is already in the DOM, so waiting for the same persistent selector does not prove that a new result loaded.

The correct sequential loop

Use for...of and await when each item depends on the previous navigation, click, or extraction:

for (const item of items) {
  await page.waitForSelector(item.selector, {
    visible: true,
    timeout: 10_000,
  });

  await processCurrentItem(page, item);
}

The loop does not start the next iteration until both the selector wait and processCurrentItem() finish. This is different from items.forEach(async item => { ... }): forEach does not await the promises returned by its callback, so the surrounding function can continue or finish while callbacks are still running.

When parallel work is intentional

If iterations are independent, start them deliberately and wait for all of them:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await Promise.all(items.map(async item => {
  await page.waitForSelector(item.selector, { visible: true });
  return processCurrentItem(page, item);
}));

Do not use this pattern with one shared page when operations can navigate or mutate the same document. Separate pages or browser contexts are usually required for genuinely concurrent browser work.

Why a repeated wait can appear to do nothing

The official Page.waitForSelector() documentation states that the promise resolves immediately if the selector already exists. Therefore, this code may not wait for a fresh result:

for (const query of queries) {
  await page.click('#next');
  await page.waitForSelector('.results'); // may already be present
  console.log(await page.$eval('.results', el => el.textContent));
}

If .results is reused for every page of results, it remains present while its text changes. Wait for an observable state transition instead: a result ID, changed text, a newly inserted item, or a page-specific selector.

Wait for a changed value in a single-page app

const previousId = await page.$eval('.result', el => el.getAttribute('data-id'));
await page.click('#next');

await page.waitForFunction(
  oldId => document.querySelector('.result')?.getAttribute('data-id') !== oldId,
  { timeout: 10_000 },
  previousId
);

const current = await page.$eval('.result', el => el.textContent);
console.log(current);

The exact condition must match the application’s DOM. A changed data-id, heading, URL, count, or loading marker is stronger evidence than the continued existence of a container.

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.

What waitForSelector actually waits for

The current official API pages display Puppeteer 25.12.0. waitForSelector resolves with an ElementHandle when the selector appears and throws if it does not appear before the configured timeout. A hidden wait can resolve to null when the selector is absent. See the WaitForSelectorOptions reference for the option contract.

Option or behavior Meaning Typical use
Default Wait for DOM presence; not necessarily visibility Elements that may be off-screen or visually hidden
visible: true Require the element to be present and visible Before clicking or reading user-visible content
hidden: true Wait until the element is hidden or absent Waiting for a spinner or overlay to finish
timeout Per-call limit; documented default is 30,000 ms Use a deliberate limit for each iteration
timeout: 0 Disable the timeout Only when an indefinite wait is explicitly intended
signal Abort the wait with an AbortSignal Cancel work when a job or request is abandoned

You can also set a global default with page.setDefaultTimeout(), but a per-iteration timeout makes failures easier to diagnose. Disabling timeouts can leave a worker stuck forever when a selector is misspelled or a page fails.

A complete page-by-page example

When each URL navigates to a new document, a stable content marker is usually sufficient. Dispose of the returned handle after extracting its data:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();

try {
  for (const url of urls) {
    await page.goto(url, { waitUntil: 'domcontentloaded' });
    const article = await page.waitForSelector('main article', {
      visible: true,
      timeout: 10_000,
    });

    try {
      console.log(await article.evaluate(el => el.textContent?.trim()));
    } finally {
      await article.dispose();
    }
  }
} finally {
  await browser.close();
}

This works only when main article is a reliable marker for every URL. If a site renders an empty article shell first, add a stronger condition, such as a title or required child element, rather than increasing the timeout indefinitely.

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

Use the right document and the right API

Selectors inside iframes

A selector in an iframe is not in the main page document. Obtain the frame and wait there. The official Frame.waitForSelector() documentation covers frame-scoped waits and navigation:

const frame = page.frames().find(f => f.url().includes('/checkout'));
if (!frame) throw new Error('Checkout frame not found');

await frame.waitForSelector('input[name="cardnumber"]', {
  visible: true,
  timeout: 10_000,
});

Prefer locators for actions

Puppeteer’s page-interactions guide recommends locators for selecting and interacting. A locator performs action precondition checks and can retry an action when the page changes. waitForSelector is a lower-level availability check; it does not automatically retry your later click, and an ElementHandle can become stale after re-rendering.

Need Better choice Reason
Confirm that a node exists waitForSelector Direct, explicit DOM wait
Click, type, or select reliably Locator Action preconditions and retries are built in
Work in an iframe Frame locator or frame method The element belongs to the frame’s document
Wait for a value to change waitForFunction or a locator condition Presence alone does not represent progress

Troubleshooting loop failures

TimeoutError on one iteration

  • Check selector spelling, escaping, and whether the page reached the expected URL.
  • Log the loop item and current URL before waiting.
  • Confirm the element is not inside an iframe or shadow-root boundary.
  • Capture HTML or a screenshot at failure time to distinguish a page-state problem from a selector problem.
  • Handle expected misses explicitly instead of setting timeout: 0.
try {
  await page.waitForSelector(item.selector, { visible: true, timeout: 10_000 });
} catch (error) {
  console.error({ item, url: page.url(), error: String(error) });
  throw error;
}

The wait resolves but data is stale

The selector probably persists across iterations. Record a previous value and wait for it to differ, or wait for a per-item selector such as [data-id="${id}"]. Also wait for a loading indicator to disappear when the application exposes one.

The click fails after the wait

The node may have been replaced between the wait and the click, covered by an overlay, or technically present but not actionable. Use a locator for the action, wait for an overlay to become hidden, or reacquire the element immediately before interacting.

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

Iterations overlap unexpectedly

Look for forEach(async ...), an unawaited helper, or a promise stored without being awaited. Use sequential for...of for shared-page workflows and return or await every asynchronous operation.

The process hangs forever

Inspect global timeout settings and calls using timeout: 0. Restore a finite timeout and add an abort signal for cancellable jobs. A timeout is diagnostic evidence that the expected state was not observed; hiding it makes recovery harder.

Performance and reliability choices

  • Use the narrowest selector that identifies the intended state; broad containers often exist before their contents are usable.
  • Set timeouts from the page’s normal latency and your job’s SLA, not from guesswork. A very short timeout creates false failures; an unlimited one consumes workers.
  • Wait for navigation and content in the order the site requires. For a click that triggers navigation, coordinate the click and navigation promises rather than waiting on an old element.
  • Reuse a browser where appropriate, but isolate pages when tasks must be concurrent.
  • Dispose of handles, close pages, and close the browser in finally blocks so a failed iteration does not leak resources.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your goal is a clean screenshot rather than browser automation, ScreenshotNeo makes one API request and returns PNG, JPEG, WebP, or PDF. It accepts cookie and consent banners before capture, removes more than 60 known consent platforms plus newsletter popups and chat widgets, and charges only for clean shots. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and billing status.

For a URL such as Stripe, the one-call cURL request is:

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.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python:

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)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

See the ScreenshotNeo documentation for options such as full-page capture, CSS selectors, device presets, dark mode, custom JavaScript, waits, blocking, PDFs, caching, bulk jobs, and signed webhooks. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to 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. Sign up free.

Frequently Asked Questions

What is Puppeteer’s documented default waitForSelector timeout?

The current 25.12.0 API documentation lists 30 seconds (30,000 milliseconds), configurable per call or with page.setDefaultTimeout().

Can waitForSelector wait for an element to disappear?

Yes. Pass hidden: true to wait until the selector is hidden or absent; a hidden wait may resolve to null when the selector is absent.

Should I use waitForSelector or a locator for a button click?

Use a locator when the goal is an action with automatic precondition checks and retries. Use waitForSelector when you specifically need a lower-level DOM-availability check.

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

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.