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.
Recommended Free Tools
#1 Best Overall
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:
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Scan for outdated or missing drivers - takes under a minuteDriver Scan →Repair Windows errors before they cause bigger problemsFix Now →Rank #2
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.
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Fix the driver behind crashes, sound loss and screen glitches3Clear out junk files and repair common Windows errorsRank #3
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.
PC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchRank #4
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 anok/errorobject 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
- Does the next action depend on the previous page state? Use
for...ofwithawait. - Are jobs independent and allowed to overlap? Use separate pages and
Promise.all(). - Can the input arrive asynchronously? Use
for await...of. - Are you only extracting a list of matching DOM nodes? Prefer one
$$eval()call. - Could the input be large? Add a concurrency limit before launching jobs.
- Can one failure be tolerated? Capture errors per item instead of relying on fail-fast aggregation.
- Does an action navigate? Start
waitForNavigation()and the action in the samePromise.all().
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.
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.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →Quick Recap
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.




