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

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Use an async function and put await inside a normal for...of (or indexed for) loop. The next iteration starts only after the awaited operation in the current iteration settles:

async function processItems(page, items) {
  for (const item of items) {
    await processItem(page, item);
  }
}

This is ordinary JavaScript serial control flow; Puppeteer does not need a special loop API. Use Promise.all only when iterations are genuinely independent and can safely overlap.

Serialize Puppeteer iterations with await

An await expression suspends the current asynchronous function until its promise fulfills or rejects. Because the expression is in the loop body, the loop cannot advance while that operation is pending.

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.
import puppeteer from 'puppeteer';

async function processItem(page, item) {
  await page.goto(item.url, { waitUntil: 'domcontentloaded' });
  return page.title();
}

async function main() {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  const items = [
    { url: 'https://example.com/one' },
    { url: 'https://example.com/two' },
    { url: 'https://example.com/three' }
  ];

  try {
    for (const item of items) {
      const title = await processItem(page, item);
      console.log(item.url, title);
    }
  } finally {
    await browser.close();
  }
}

main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

Here, navigation and title extraction for item two cannot begin until item one has completed. This pattern is appropriate when work depends on an earlier result, uses one page’s mutable state, or must preserve side-effect order.

Why forEach is usually wrong

Array.prototype.forEach does not await an asynchronous callback. All callbacks are started without the loop waiting for their returned promises:

items.forEach(async item => {
  await processItem(page, item);
});
console.log('This may run before any item finishes');

Use for...of, an indexed loop, or explicitly construct and await promises instead.

Indexed loops and collecting results

const titles = [];
for (let index = 0; index < items.length; index += 1) {
  titles.push(await processItem(page, items[index]));
}

The result array is populated in processing order. If one operation rejects, execution leaves the loop unless you catch that error inside the iteration.

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.

When iterations may run concurrently

If every task is independent and can use separate browser contexts or pages, start them together and await the aggregate:

const results = await Promise.all(
  items.map(item => processItemOnOwnPage(browser, item))
);

Promise.all fulfills after every supplied promise fulfills and returns values in input order, even if completion order differs. It rejects when any input rejects. Promises that were already started are not automatically canceled, so a rejection does not stop sibling browser work.

Do not share a stateful page casually

Concurrent calls against one page can race over URL, cookies, dialogs, form fields, and DOM state. A safer concurrent design creates a page per task:

async function processOnOwnPage(browser, item) {
  const page = await browser.newPage();
  try {
    await page.goto(item.url, { waitUntil: 'domcontentloaded' });
    return await page.title();
  } finally {
    await page.close();
  }
}

Even with separate pages, consider the target site’s rate limits and your machine’s CPU, memory, and network capacity. Concurrency is a correctness decision first and a performance decision second.

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

Handling failures while aggregating

To keep one failed item from rejecting the entire aggregate, convert each outcome to a value:

const outcomes = await Promise.all(items.map(async item => {
  try {
    return { item, value: await processOnOwnPage(browser, item) };
  } catch (error) {
    return { item, error: error.message };
  }
}));

This records failures while allowing other tasks to finish. If failure should stop processing and preserve order, use the serial loop and catch at the boundary you choose.

Clicks that trigger navigation

Do not await a click and only then begin waiting for navigation. The navigation can start during the click, creating a race in which the wait is installed too late. Start both promises together:

await Promise.all([
  page.waitForNavigation(),
  page.click('a.next')
]);

Inside a serial loop, await this pair before advancing:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
for (const selector of nextLinkSelectors) {
  await Promise.all([
    page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
    page.click(selector)
  ]);
  await readCurrentPage(page);
}

If the click does not navigate, do not add a navigation wait merely to slow the loop. Wait for the state change that defines completion, such as a result element, URL change, or application-specific marker.

Waiting for elements is different from waiting for the previous iteration

A selector wait answers “is this element available?” It does not by itself serialize your outer loop. Puppeteer’s documented default timeout for page.waitForSelector() is 30 seconds; set a per-call timeout when a page needs a different limit.

await page.waitForSelector('.result', { timeout: 10000 });

Puppeteer recommends locators for selecting and interacting with elements. Locator actions wait for documented interaction preconditions, including visibility, enabled state, and a stable bounding box:

await page.locator('button.next').click();

Use a locator when the supported action itself expresses what must be ready. Use waitForSelector when you need an explicit readiness checkpoint before reading, evaluating, or performing another operation.

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

A complete serial example

async function processItems(page, items) {
  for (const item of items) {
    const selector = `[data-id="${CSS.escape(item.id)}"]`;
    await page.locator(selector).click();
    await page.waitForSelector('.result');
    const text = await page.locator('.result').textContent();
    console.log(item.id, text);
  }
}

The selector, result condition, and whether a click navigates depend on the target site. If it navigates, replace the click with the paired waitForNavigation/click pattern.

page.evaluate() and loop sequencing

Puppeteer automatically awaits a promise returned by the function executed in the browser context:

const value = await page.evaluate(async () => {
  const response = await fetch('/api/status');
  return response.json();
});

That behavior only waits for the page-side function’s returned promise. Keep coordination between loop iterations in the Node.js function:

for (const item of items) {
  const value = await page.evaluate(async id => {
    const response = await fetch(`/api/items/${id}`);
    return response.json();
  }, item.id);
  await save(value);
}

Timeouts, retries, and cleanup

Set timeouts deliberately

A wait that is too short produces false failures; an unlimited wait can stall a batch forever. Set navigation and selector limits appropriate to the site, and log the item being processed so a timeout is actionable.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(30000);

Retry only retryable operations

A retry can help with transient navigation or network failures, but repeating a non-idempotent click may submit a form twice. Keep retries bounded and re-establish the page state before retrying.

async function withRetry(operation, attempts = 3) {
  let lastError;
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;
      if (attempt === attempts) break;
    }
  }
  throw lastError;
}

Always close pages and browsers

Put cleanup in finally. This prevents a rejected iteration from leaving Chromium processes or pages running and consuming resources.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Common mistakes and fixes

  • The next iteration starts immediately: the callback was launched with forEach or promises were not awaited. Replace it with for...of and await the operation in the body.
  • page.waitForNavigation() times out after a click: the wait was started after the click, or the click changes the DOM without navigation. Start both together, or wait for the actual DOM marker.
  • Results belong to the wrong item: concurrent tasks share one page. Serialize them or allocate one page/context per task.
  • waitForSelector times out: verify the selector, frame, visibility assumptions, and timeout. If the element is inside an iframe, obtain the correct frame before waiting.
  • A later task never runs after one error: an unhandled rejection exited the serial loop. Catch per item if you want to continue, or catch once at the outer boundary if fail-fast behavior is intended.
  • Browser memory grows during a batch: close task pages in finally, limit concurrency, and avoid retaining large page results unnecessarily.

Or skip the browser setup

If your goal is simply to capture pages rather than automate a stateful browser workflow, ScreenshotNeo provides a single HTTP request. Its clean-shot steps accept cookie or consent banners and remove more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status.

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 ScreenshotNeo documentation for all options. The same endpoint works from Python:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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)

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

ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

Frequently Asked Questions

Does adding await to a loop make Chromium single-threaded?

No. It serializes the awaited JavaScript operations in that function. Chromium can still perform internal work, but your next iteration does not start until the awaited promise settles.

Should I use Promise.allSettled instead of Promise.all?

Use Promise.allSettled when you need every fulfillment and rejection outcome without failing at the first rejection. Use Promise.all when one failure should reject the aggregate.

Can I use a locator and waitForSelector together?

Yes, but avoid redundant waits. A locator action usually supplies the interaction preconditions; add an explicit selector wait when you need a separate readiness checkpoint or are about to read state.

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.