Taking one screenshot with Puppeteer is twenty minutes of work. Taking a few million is a different job entirely.
I found that out building Screenshotline, a screenshot API. What surprised me was not how much broke. It was how little of it looked broken. Almost nothing crashed. The failures were a 200 OK with the wrong picture in it — a cookie wall, a half-loaded page, a grey hole, a blank white frame — which a pipeline records as a success and nobody notices for a month.
So I built a benchmark of 202 real websites — news sites, shops, SaaS sites, docs, government pages, sites in other languages and scripts — and ran every change against it. The failures were consistent enough to be worth writing down. Here are the six that cost the most, each with the fix and the actual code, and then the bug that was worse than all of them.
1. Consent banners arrive after the page settles
The symptom. Your screenshot is a cookie wall, even though you remove cookie banners.
Why it happens. The obvious approach is a sweep at capture time: find the consent dialog, remove it, take the picture. But many sites inject the banner late — after the page has loaded, sometimes after the network has gone quiet. nytimes.com is one. A sweep that runs at capture time is simply too early, and it loses that race every time.
The fix. Don't sweep once. Install a watcher before any of the page's own scripts run, and keep sweeping every time the DOM changes:
await page.evaluateOnNewDocument(COOKIE_OBSERVER_SCRIPT);
where the script is, in part:
const schedule = () => {
if (scheduled) return;
scheduled = setTimeout(run, 50);
};
const start = () => {
if (!document.documentElement) return requestAnimationFrame(start);
new MutationObserver(schedule).observe(document.documentElement, {
childList: true,
subtree: true,
});
run();
};
The 50ms debounce matters: a busy page mutates its DOM thousands of times, and sweeping on every mutation would be pure waste.
One more thing that took a while to find: some consent managers render inside a shadow root, and querySelectorAll does not cross a shadow boundary. On dw.com the sweep was looking straight past a full-page modal. The sweep now walks open shadow roots too.
2. Pages that never go network-idle
The symptom. Timeouts on pages that load perfectly well in your browser.
Why it happens. networkidle2 — wait until there are no more than two network connections for half a second — is the obvious default, and it is wrong for the real web. Plenty of sites hold a connection open forever for analytics beacons, long-polling or ad refresh. They painted seconds ago; they will never be "idle". Across the
benchmark, waiting for it caused 18 hard timeouts. Waiting for load instead gave a median of 23 seconds, because 16 of the 20 SaaS pages in the su
The fix. Navigate to domcontentloaded, then spend one bounded settle budget on the things that actually affect the picture — load if it comes soonages and video in view decoding — and capture when the budget runs out:
response = await page.goto(options.url, { waitUntil: options.waitUntil });
// ...
const deadline = Math.min(
Date.now() + options.settle,
startedAt + options.timeout - 6000,
);
The second line of that Math.min is a lesson on its own: the settle budget has to leave room for the capture inside the overall timeout, or a page that takes 24 seconds to navigate settles its way straight past a 30-second ceiling.
A fixed budget still can't serve both a static page and a heavy news homepage. timesofindia.indiatimes.com returned 446 characters of text with a 5-second settle and 48,755 with 15 seconds — and the short version came back as a clean 200. So the settle has an adaptive tail: it keeps waiting while the page's text is still
growing, and stops early once there is plainly enough of it.
3. Pages that wedge their own main thread
The symptom. A single page times out, and takes a browser down with it.
Why it happens. When a page blocks its main thread, everything you ask of it hangs: page.evaluate, page.screenshot, and even page.close(). Nons own. vercel.com did this in the benchmark: the document was complete at 3.6 seconds, then four JavaScript chunks stayed in flight forever and the pagestopped answering anything. One stuck page burns the whole render budget.
I blamed this on concurrency for longer than I'd like to admit. Then I ran the page on its own, with nothing else happening, and watched it hang there too.
The fix. Every wait gets its own ceiling. Anything you ask the page for is optional — a measurement, a sweep — so time it out and carry on with what is already on screen:
function atMost(promise, ms) {
return Promise.race([
promise.catch(() => null),
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
function evaluateAtMost(page, script, ms, fallback = null) {
return atMost(page.evaluate(script), ms).then((v) => (v === null ? fallback : v));
}
And a page that misses one deadline has almost always stopped answering for good, so after the first miss, stop asking — every further timed-out call ishole has a wall-clock ceiling, and when it fires the browser process is killed, not politely closed: close() waits on the very thing that hung.
4. Blocked ads leave a hole
The symptom. You block ads, and the screenshot has a big grey rectangle pushing the real content below the fold.
Why it happens. Blocking the ad request stops the ad. It does not give back the space: the page reserved that slot's height before the ad arrived, a
The fix. After the page has settled, find ad containers that are still empty and collapse them. The part that matters is not collapsing anything rea— whole class-name tokens, never substrings, because a substring match on "ad" eats header, shadow, download and gradient:
for (const token of raw.split(/[^a-z0-9]+/)) {
if (!token) continue;
if (AD_TOKENS.has(token)) return true;
for (const p of AD_PREFIXES) if (token.startsWith(p)) return true;
}
and a slot is only collapsed if it has no text, nothing painted inside it, and is not large enough to be the page itself. It runs after the settle, sohouse ad is left alone. On france24.com this took the capture from 122KB to 234KB of actual content.
(A caution about that number: file size is a bad proxy for quality in both directions. Removing a consent modal makes a capture smaller, and one compere was blurry because it was taken mid-paint. Open the image. Always open the image.)
5. The blank capture
The symptom. None, which is the problem. A bot wall or a page that needs JavaScript you blocked renders a blank white frame, the API returns 200, an success.
The fix. Detect it, and say so in a response header. Neither signal is trustworthy alone. An empty DOM flags legitimately sparse pages (badssl.com iackground), and low bytes-per-pixel flags anything simple. So both have to agree:
const domBlank = content.textLength < 20 && content.images === 0 && content.elements < 15;
const visuallyBlank = calibrated && bytesPerPixel < 0.008 && content.textLength < 400;
const blankSuspected =
detectorApplies &&
options.format !== 'pdf' &&
(visuallyBlank || (domBlank && (!calibrated || bytesPerPixel < 0.03)));
Two details in there each came from a real mistake. detectorApplies is false for a successful response that isn't HTML: Chrome's own JSON or image viewer is legitimately sparse, and a JSON endpoint was being flagged as a blank capture. And calibrated restricts the pixel signal to the configuration its thresholds were measured on
— a PNG of a normal viewport — because bytes-per-pixel measures the encoding as much as the page. A 3840×4320 capture of a small page is mostly white wrong, and a quality-1 JPEG is tiny whatever it shows.
Honest reporting also meant going through every "hard" page that passed by hand. Of the 20 pages the suite marks as known-hard, 14 returned something.uinely the real page, 2 were captchas that a naive count would have scored as wins, and 1 was a dead URL in my own test suite.
6. Zombie Chrome
The symptom. Throughput slowly collapses and memory climbs, with no obvious cause.
Why it happens. When Chrome crashes mid-render, its child processes are orphaned. Inside a container, whatever is PID 1 is supposed to reap orphans rocess, nothing does. They sit as <defunct> entries forever, while your pool believes those slots are free.
The fix is one line, and it cost me an afternoon to find:
ENTRYPOINT ["/usr/bin/tini", "--"]
(docker run --init, or init: true in Compose, does the same.) Pair it with recycling each browser after a fixed number of renders, because Chrome dos navigations.
The bug that had no symptom
Two things I got wrong are worth more than the list above.
The consent-banner heuristic ate apnews.com's entire site header one day. Its trending strip carried the headline "Girl Scout cookies". The keyword testn-word test — looking for buttons like "OK" and "Accept" — matched ok inside c-o-ok-i-e-s, because I had not put word boundaries in the regex. One word in a news headline passed both tests, and the sweep removed a header containing 1,022 links and two <nav> elements. The fix that actually holds is structural rather than lexical: a consent banner is never the site's navigation.
Then, fixing that, I introduced something worse.
The scripts that run inside the page are built as JavaScript template literals — backtick strings — so they can be injected with evaluateOnNewDocument. Inside a template literal, a lone \b is not a word boundary. It is an escape sequence, and it collapses into a single backspace character, U+0008. So my new, correct-looking
regex:
const SWEEP_BODY = `
const ACTIONS = /\b(accept|agree|reject|allow|ok)\b/i;
`;
compiled, inside the page, into a regex that matched a backspace, which never appears on a web page. It matched nothing. The whole consent heuristic wass. Every test passed. Every page still rendered. The apnews header "survived" my new guard only because the heuristic had stopped removing anything atall.
Inside a template literal, every backslash has to be doubled:
const ACTIONS = /\\b(accept|agree|reject|allow|manage|continue|got it|ok|okay)\\b/i;
and \b is not the only one: \f, \v and \0 collapse the same way, and \s quietly becomes the letter s. There is now a test that scans every script we inject for control characters — shown here with escapes, because the real test file contains the characters themselves:
const collapsed = /[\x00\x08\x0B\x0C]/;
for (const [name, src] of Object.entries(scripts)) {
const at = src.search(collapsed);
assert(at === -1, `${name} contains a control character at ${at}`);
}
That test is the only reason this class of bug is visible at all. Nothing else in the system could have noticed: the code was valid, the pages rendered, and the output was merely wrong.
What I'd tell someone starting
Screenshotline's benchmark numbers today: of the 182 pages in the suite that should work, all 182 capture correctly, with a median of 5.7 seconds on the benchmark machine. The other 20 are named as hard, and the ones that return something are checked by eye.
If you need screenshots at volume, you can absolutely build this yourself — every fix above is a few dozen lines. But you will own this list, and the list grows. That is the whole case for an API.
Screenshotline is open source (AGPL-3.0): the renderer that runs the hosted API is the same code, with nothing held back. If you'd rather someone else's pager went off, the hosted version has 500 free renders a month and no card.
Top comments (0)