A Puppeteer screenshot that comes out white, black or soft is one of a handful of causes, and each has a one-line fix once you know which one you have. On this page every cause is reproduced on a small test page, with the measured result before and after the fix.
How we tested. On 2026-11-08, Puppeteer 25.7 with its bundled headless Chrome, against a local Node server with four pages: a single-page app that fetches its content (the API answers after 800 ms), a page that fades in over 1.2 s, a page with a transparent background, and a paragraph of 16 px text. "Ink" below is the share of pixels that are not near-white (under 230 of 255 in grey): 0% means an empty white picture.
White: the content is not there yet
await page.goto('http://localhost:8098/spa', { waitUntil: 'load' });
await page.screenshot({ path: 'spa.png' }); // ink 0.00% — completely white
The HTML of a single-page app is an empty <div id="app">. The load event fires when that HTML, its script and its images are in — before the script has fetched the data and drawn the page. The screenshot shows exactly what was there: nothing.
Two fixes, both gave the full page (ink 2.23%):
// Wait for an element of the real content (best: precise, no idle guessing).
await page.goto('http://localhost:8098/spa');
await page.waitForSelector('#title');
// Or wait for the network to go quiet (works when the content needs a request).
await page.goto('http://localhost:8098/spa', { waitUntil: 'networkidle0' });
networkidle0 waits until no request has been open for 500 ms. It worked here, but on pages with analytics beacons, polling or a chat widget it waits for its full timeout or never settles; waiting for page load measures the options side by side. A selector of the content is the dependable signal.
White again: the page is fading in
await page.goto('http://localhost:8098/fade');
await page.screenshot({ path: 'fade.png' }); // ink 0.00% — the text is at opacity 0
Landing pages often animate their content in: opacity: 0 to 1 and a small slide over a second or so. Right after goto the animation has only started, so the capture is white, or in a longer animation half-transparent. Both fixes gave the finished page (ink 3.32%):
// Ask for no motion — most sites honour it (this one has a reduced-motion rule).
await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]);
await page.goto('http://localhost:8098/fade');
// Or wait until every running animation has finished.
await page.goto('http://localhost:8098/fade');
await page.evaluate(() => Promise.all(document.getAnimations().map((a) => a.finished)));
document.getAnimations() returns the CSS animations and transitions running in the document; waiting on their finished promises ends when the last one does. Do not use it on a page with an infinite animation (a spinner): that promise never resolves. prefers-reduced-motion only helps on sites that have a rule for it.
Black: a transparent picture shown without a background
await page.goto('http://localhost:8098/clear');
await page.screenshot({ path: 'clear.png', omitBackground: true });
omitBackground "hides default white background and allows capturing screenshots with transparency". On a page without its own background colour, the PNG is transparent everywhere except the text. Opened in a viewer with a dark background, or converted to RGB without a background — Pillow's convert("RGB") gave a picture of mean brightness 1 out of 255 — it looks black. The same option with type: 'jpeg' came out white, because JPEG has no transparency and Chrome fills it.
Fix: leave omitBackground out unless you need transparency, or flatten the PNG onto white when you convert it. If the page itself sets a dark background, the screenshot is right and the page is dark.
Other black or empty pictures we did not reproduce here but are common: WebGL and <canvas> content in a headless Chrome without GPU support, and video elements, which may show their poster or nothing. For those, test with a headed browser to see whether the page draws them at all in your setup.
Blurry: too few pixels for the screen
With the default deviceScaleFactor: 1, an 800×400 viewport gives an 800×400 picture. That is correct, but a retina laptop or a phone shows it at two or three physical pixels per picture pixel, so text looks soft. With deviceScaleFactor: 2 the same page gave a 1600×800 picture with the text drawn at full resolution — 39 KB as PNG instead of 15 KB.
await page.setViewport({ width: 800, height: 400, deviceScaleFactor: 2 });
Two more causes of blur:
- JPEG at low quality. At
quality: 30the 16 px paragraph got grey blocks and halos around every letter; the file shrank from 15 KB (PNG) to 8.7 KB. For screenshots with text, use PNG or WebP, or JPEG at 80 and above. - Scaling after capture. A picture enlarged in an
<img>, a slide or a document is resampled and blurred. Capture at the size you show it at, or larger, and only ever shrink.window.devicePixelRatioin the target browser tells you how many pixels per CSS pixel it needs.
A checklist
| Symptom | Cause | Fix |
|---|---|---|
| All white | Content drawn by script after load |
waitForSelector of the content, or networkidle0 |
| White or faint | Fade-in animation running | prefers-reduced-motion: reduce, or wait for getAnimations() |
| Black | Transparent PNG viewed or converted without a background | Drop omitBackground, or flatten onto white |
| Soft text | deviceScaleFactor: 1 shown on a retina screen |
deviceScaleFactor: 2 |
| Blocky text | Low JPEG quality | PNG/WebP, or JPEG ≥ 80 |
| Missing images further down | Lazy loading, below the fold | Scroll through first; see full-page screenshots |
| Wrong page, no error | Screenshot during a redirect | Wait for the final page; see execution context destroyed |
The same fixes in our API
If the job is "a good picture of a public URL", our screenshot API sets these for you or exposes them as options: it waits for the DOM, the load event (up to a few seconds) and a quiet network before capturing; wait_for waits for a selector like waitForSelector; delay adds seconds; reduced_motion: true sets prefers-reduced-motion: reduce; scale 2 or 3 gives retina pictures; quality defaults to 85 for JPEG and WebP; lazy_load scrolls through the page first. It does not wait for CSS animations by itself, so on a fade-in page add reduced_motion or a delay.
curl -X POST "https://shareglint.com/v1/screenshots?wait=true" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/", "wait_for": "#app h1", "reduced_motion": true, "scale": 2}'
A viewport capture is 1 render of the quota, a full page 2 (pricing). For your own Puppeteer code, the basics are in Puppeteer screenshots.