A slow Puppeteer screenshot is almost never slow because of the screenshot. In our measurements the capture itself took 50 to 300 milliseconds; loading the page took seconds. So the changes that help are about what you wait for, what you load, and how many browsers you start. Below are seven changes, each measured on 2026-11-06 with Puppeteer 25.7.0 in the official ghcr.io/puppeteer/puppeteer image, limited to 2 CPU cores, against a local test page: a heading, 30 images that each take 100 ms to arrive, and one third-party-style script that takes 1.5 s. Absolute times on your pages will differ; the differences between the options are what carry over.
1. Keep one browser running
| Setup | Time per screenshot |
|---|---|
puppeteer.launch() and close() for every shot |
2,790 ms |
| One browser, a new page per shot | 2,152 ms |
| One browser, one page reused | 2,103 ms |
Starting Chrome cost about 640 ms per shot here — more on a busy server or a cold container. Launch once, keep the browser, and open a page per job. Reusing the same page saved almost nothing more, and a fresh page avoids state from the previous site (cookies, service workers, a dialog left open), so a new page per job, closed afterwards, is the better default.
const browser = await puppeteer.launch(); // once, at startup
async function screenshot(url) {
const page = await browser.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
return await page.screenshot({ type: 'jpeg', quality: 80 });
} finally {
await page.close(); // always, or pages pile up
}
}
Watch for browser.on('disconnected') and relaunch when it fires, and consider restarting the browser now and then on long-running workers, since a long-lived Chrome process can grow in memory.
2. Choose waitUntil on purpose
waitUntil |
Time | Images loaded | Page with a request that never ends |
|---|---|---|---|
domcontentloaded |
58 ms | 0 of 30 | 19 ms |
load |
2,038 ms | 30 of 30 | 2,050 ms |
networkidle2 |
1,116 ms | 30 of 30 | 1,220 ms |
networkidle0 |
2,570 ms | 30 of 30 | timed out after 15 s |
What this shows:
domcontentloadedis fast and empty — none of the images had arrived. Use it only if you then wait for something specific (page.waitForSelector,document.fonts.ready, imagecomplete).loadwaits for the slow script. The async script that took 1.5 s held theloadevent, although the page looked finished long before.networkidle2(no more than two connections for 500 ms) was the fastest option that had every image, and it shrugged off the slow script.networkidle0never finished on a page with one hanging request (a long poll, analytics beacon or chat widget does this).
Puppeteer's documentation for page.goto defines the four values. A good default is networkidle2 with an explicit timeout, plus a short wait for the element you care about.
3. Do not load what the screenshot does not need
Aborting the slow script with request interception brought the networkidle0 load from 2,570 ms down to 1,238 ms on the test page. In practice, block by domain:
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = request.url();
if (/googletagmanager|google-analytics|hotjar|intercom|doubleclick/.test(url)) return request.abort();
request.continue();
});
Block by domain rather than by type: blocking every script breaks pages that render with JavaScript. Analytics, chat widgets, ad and A/B-testing scripts are safe to drop for a screenshot and are often the slowest requests on a page.
4. Pick the format by speed and size
Capture time for a 1280×800 viewport, average of five, and file size (the size depends heavily on the page — ours had many small photo-like images):
| Format | 1× | 2× (retina) |
|---|---|---|
| PNG (default) | 77 ms, 255 KB | 290 ms, 956 KB |
PNG, optimizeForSpeed: true |
46 ms, 302 KB | 117 ms, 1,249 KB |
| JPEG, quality 80 | 51 ms, 551 KB | 117 ms, 1,424 KB |
| WebP, quality 80 | 164 ms, 552 KB | 484 ms, 1,415 KB |
optimizeForSpeed (Puppeteer docs) uses faster PNG compression: 2.5 times faster at 2× here, for a file about 30% larger. WebP was the slowest to encode. If you capture thousands of pages, PNG with optimizeForSpeed or JPEG is the cheap choice; compress afterwards if storage matters more than time. One more reason to be careful with WebP and JPEG: for very tall full-page captures they return an empty buffer (WebP above 16,383 px, JPEG above 65,500 px) — see full-page screenshots.
Retina (deviceScaleFactor: 2) is four times the pixels, and every format took 2.3–3.8 times longer. Use it when the image will be shown at retina size, not by default.
5. Run pages in parallel — across different sites
Eight screenshots with one browser:
| One at a time | Eight at once | |
|---|---|---|
| Eight different hosts | 5,166 ms | 2,038 ms |
| The same host eight times | 5,290 ms | 4,684 ms |
Parallel pages pay off across sites. Against one site they barely helped: Chrome allows a limited number of connections per host, and all pages of one browser share them, so eight pages of the same site queue behind each other. For many pages of one site, the site's server sets the pace; spreading the work over time (or over separate browser processes, each with its own connections — measure it for your site) is the way to go faster, not more tabs.
Cap the concurrency (a small pool, for example with a counter or p-limit): every open page holds memory and CPU, so find the number where your machine stops getting faster.
6. Make the viewport no bigger than needed
A screenshot's cost grows with its pixels: the 2× capture above has four times the pixels of 1× and took up to 3.8 times as long. Set the viewport to the size you will publish, and capture an element (elementHandle.screenshot()) instead of the full page when you need only part of it.
7. Cache, and do not screenshot twice
The fastest screenshot is the one you do not take. If the same URL is requested again within minutes, serve the stored file. Key the cache on everything that changes the image: URL, viewport, scale, format, and the time window after which the page may have changed.
What did not make a measurable difference
- Reusing the same page instead of a new page per shot: 2,103 vs 2,152 ms.
captureBeyondViewportfor viewport captures: it matters for full-page captures, not here.
Or skip the browser
If what you are building is a screenshot feature rather than a browser farm, Shareglint's screenshot API runs the pool, the waiting rules, cookie-banner handling and the cache for you: the same URL with the same options within 24 hours is returned without rendering, and a viewport capture costs 1 render of quota (JavaScript example, pricing). For pages behind a login or on your own network, your own Puppeteer — with the changes above — is the right tool.