Hardware FixRecommendedDevice not working? Your driver may be the problemCheck updates for common hardware issues.Fix DriversOctober DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsClean PCRecommendedOne scan can reveal what keeps slowing WindowsLook for cleanup and repair opportunities.Run Scan×
Skip to content
async await

How to Iterate Asynchronous Puppeteer Functions with Node.js

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

Use for...of with await when Puppeteer actions depend on one another or must run in a fixed order. Use map() with Promise.all() only when jobs are independent and each job has its own page. Choose for await...of when the input is an asynchronous iterable. Avoid forEach(async ...): it starts callbacks but gives you no promise representing the whole loop.

Choose the iteration pattern before writing the loop

Puppeteer methods such as page.goto(), page.title(), page.evaluate() and page.$$eval() return promises. The correct loop depends on whether the next operation may begin before the previous one settles, whether browser state can be shared, and how you want failures reported.

Pattern Ordering Page isolation Error behavior Best fit
for...of plus await Strict sequence Usually one shared page Stops at the first uncaught error Dependent navigation, login state, rate-sensitive work
map() plus Promise.all() Jobs overlap; result array follows input order One page per job is safest Aggregate rejects when a job rejects Independent URLs or records
for await...of Awaits each item from an async or sync iterable Whatever the loop body chooses Errors can be handled around each iteration Paginated APIs, async generators, streams
page.$$eval() One page-context operation over matching elements The current page Rejects if the page function rejects Extracting many elements without Node-side iteration

Concurrency is an engineering choice, not a guarantee that every site or machine gets faster. More pages consume more memory and may trigger target-site throttling, so bound concurrency for large inputs.

Run dependent work sequentially with for...of

Reuse a page when each navigation or extraction changes state needed by the next iteration. Every loop body below completes before the next URL starts.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
try {
  const urls = ['https://example.com/a', 'https://example.com/b'];
  const results = [];

  for (const url of urls) {
    await page.goto(url, {waitUntil: 'domcontentloaded'});
    const title = await page.title();
    results.push({url, title});
  }

  console.log(results);
} finally {
  await page.close();
  await browser.close();
}

The first await settles navigation, then the title is read, then the result is stored. This ordering prevents a second goto() from replacing the document while the first extraction is still running. It also preserves cookies, local storage and other state on the shared page.

Why forEach(async ...) does not wait

// The outer function finishes before these callbacks finish.
urls.forEach(async url => {
  await page.goto(url);
  console.log(await page.title());
});

forEach() ignores the promises returned by its callback. There is nothing to await for the group, and simultaneous operations on one page can overwrite each other. Replace it with the sequential loop above, or return promises from map() and await an aggregate deliberately.

Overlap independent jobs with separate pages

When URLs do not share state and may load at the same time, create a page inside each job and close it in that job’s finally block.

const pages = await Promise.all(
  urls.map(async url => {
    const p = await browser.newPage();
    try {
      await p.goto(url, {waitUntil: 'domcontentloaded'});
      return {url, title: await p.title()};
    } finally {
      await p.close();
    }
  }),
);

console.log(pages);

Promise.all() fulfills with values in the same order as the input array, even if a later URL finishes first. A rejection rejects the aggregate, so use an explicit per-item result when partial success matters:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const results = await Promise.all(urls.map(async url => {
  const p = await browser.newPage();
  try {
    await p.goto(url, {waitUntil: 'domcontentloaded'});
    return {url, ok: true, title: await p.title()};
  } catch (error) {
    return {url, ok: false, error: error instanceof Error ? error.message : String(error)};
  } finally {
    await p.close();
  }
}));

This keeps one failed URL from hiding successful results. It does not make unlimited concurrency safe: mapping thousands of URLs at once can exhaust browser resources.

Bound concurrency for large inputs

A small worker pool limits the number of open pages while keeping each worker’s navigation and extraction sequential. The limit of four below is an example policy; choose a value that fits your machine and the target site’s rules, then measure your own workload rather than assuming a speedup.

const nextIndex = {value: 0};
const results = [];
const workerCount = Math.min(4, urls.length);

async function worker() {
  const p = await browser.newPage();
  try {
    while (true) {
      const index = nextIndex.value++;
      if (index >= urls.length) return;
      const url = urls[index];
      try {
        await p.goto(url, {waitUntil: 'domcontentloaded'});
        results[index] = {url, ok: true, title: await p.title()};
      } catch (error) {
        results[index] = {
          url,
          ok: false,
          error: error instanceof Error ? error.message : String(error),
        };
      }
    }
  } finally {
    await p.close();
  }
}

await Promise.all(Array.from({length: workerCount}, worker));
console.log(results);

Each worker owns one page, so no two workers call goto() or click on the same page. Storing by index retains input order while allowing jobs to finish at different times.

Use for await...of for asynchronous producers

for await...of awaits each next() result from an async iterable. It also accepts ordinary synchronous iterables, but a plain array is usually clearer with for...of.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
async function* urlsFromApi(urls) {
  for (const url of urls) {
    // Replace this yield with a paginated API request when needed.
    yield url;
  }
}

for await (const url of urlsFromApi(urls)) {
  await page.goto(url, {waitUntil: 'domcontentloaded'});
  console.log(await page.title());
}

Use this form when the producer itself is asynchronous, such as a page-by-page API reader. If the loop exits early, the iterator’s return cleanup is performed, which lets a well-designed async generator release its resources.

Process many elements with $$eval()

Do not create a Node-side asynchronous loop when the work is simply “read every matching element.” page.$$eval() passes the matched elements to a function in the browser context and waits if that function returns a promise.

const links = await page.$$eval('a.card', async cards => {
  return cards.map(card => ({
    text: card.textContent?.trim() ?? '',
    href: card.href,
  }));
});

console.log(links);

The callback must be self-contained browser code and should return serializable data. Node.js modules, filesystem variables and local objects are not automatically available inside it. Pass values explicitly:

const suffix = ' (captured)';
const labels = await page.$$eval('h2', (heads, suffix) =>
  heads.map(head => `${head.textContent?.trim() ?? ''}${suffix}`),
  suffix,
);

The same boundary applies to page.evaluate(): it runs in the page, and Puppeteer waits when the supplied function returns a promise.

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

Pair navigation waits with the action that triggers navigation

Start the navigation wait before clicking. Running the two promises together prevents a fast navigation from occurring before the listener is installed.

const [response] = await Promise.all([
  page.waitForNavigation({waitUntil: 'domcontentloaded'}),
  page.click('a.next'),
]);

console.log(response?.url());

Keep state-changing operations sequential on a page: wait for a click-triggered navigation before selecting the next element, submitting another form or reading the new document.

Keep browser-context and Node.js errors understandable

  • Shared-page races: simultaneous goto(), clicks or form submissions can replace page state. Serialize them or allocate separate pages.
  • Aggregate rejection: Promise.all() fails as soon as one promise rejects. Return an ok/error object per job when you need a complete report.
  • Unbounded input: a large map() creates all jobs immediately. Use fixed-size batches or a worker pool.
  • Context confusion: code in evaluate() and $$eval() cannot use Node-only variables unless they are passed as arguments.
  • Transpiled callbacks: Puppeteer serializes evaluate callbacks. Babel or TypeScript transformations can change the function source so an async callback no longer works. Preserve modern syntax targeting ES2018 or use Puppeteer’s documented string-template workaround.
  • Leaks after errors: close each page in finally, then close the browser after the batch. Dispose any handles when they are no longer needed.

A practical decision checklist

  1. Does the next action depend on the previous page state? Use for...of with await.
  2. Are jobs independent and allowed to overlap? Use separate pages and Promise.all().
  3. Can the input arrive asynchronously? Use for await...of.
  4. Are you only extracting a list of matching DOM nodes? Prefer one $$eval() call.
  5. Could the input be large? Add a concurrency limit before launching jobs.
  6. Can one failure be tolerated? Capture errors per item instead of relying on fail-fast aggregation.
  7. Does an action navigate? Start waitForNavigation() and the action in the same Promise.all().
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 image or PDF rather than custom Puppeteer logic, ScreenshotNeo provides a single HTTP request and an MCP server for AI clients such as Claude and Cursor. Before capture it accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups and chat widgets. Only clean shots are billed: bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits cost nothing, and the response identifies the result with X-Page-Verdict and X-Billed headers.

See the parameter reference in the ScreenshotNeo documentation. The same endpoint supports PNG, JPEG, WebP or PDF output and options such as full-page capture, CSS selectors, dark mode, device presets, custom CSS or JavaScript, waits, request blocking, cookies, headers, geolocation, caching, signed links, asynchronous webhooks and bulk capture.

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

cURL

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}`);

There is a free allowance of 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots, and every feature is included on every plan. The MCP tools are named take_screenshot, get_page_info and capture_pdf. Create a free ScreenshotNeo account to try the request.

Frequently Asked Questions

Can I combine concurrency with sequential steps?

Yes. Give each worker its own page, then keep that worker’s navigation, waits and extraction in a sequential for...of flow. Workers may overlap with one another without racing on a shared page.

How should credentials or configuration reach an evaluate callback?

Pass only the needed, serializable values as arguments to page.evaluate() or page.$$eval(); do not expect Node.js imports or outer variables to exist in the browser context.

Is a concurrency limit part of Puppeteer’s API?

No. Limiting workers or batching inputs is your application’s resource and politeness policy, chosen for the browser host and target site.

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

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.

Read next

Recommended PC Tool
Recommended PC Tool
Crashes, No Sound, or Screen Glitches?Free driver scan
PC Slower Than It Used to Be?Free scan - under a minute

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.