Puppeteer Screenshot Blank? Causes and a Tested Fix
Puppeteer screenshot blank or white? We reproduced the common causes on Puppeteer 25, counted white pixels in each capture, and show the wait that fixes them.
A blank Puppeteer screenshot is almost always a timing problem, not a rendering bug. page.screenshot() captures the frame that exists at that instant. If the page fills itself from JavaScript, fades in, or waits for a web font, that instant can be a white rectangle. The fix is to wait for evidence that the content is painted: a visible element, loaded fonts, finished animations. Do not wait for a guess like network idle.
This is a spoke of our Puppeteer screenshot guide. Below are five causes, how "blank" each capture was by pixel count, and one wait sequence that fixed every case we tested. We reproduced four of the causes on test pages; the web font case (cause 3) is described from browser behaviour and was not reproduced.
Tested on 2026-10-18 with Node 22.18.0, [email protected] (Chrome 155.0.8059.79, new headless mode), pngjs@7, macOS on Apple silicon. Viewport 800×600. Test pages served from a local Node HTTP server.
How we measured "blank"
We decoded every PNG with pngjs and counted pixels where any of R, G or B was below 250. A blank white capture scores 0%. This catches the case people actually report: a file of the right size that shows nothing.
import { PNG } from 'pngjs'; export function nonWhitePercent(buffer) { const png = PNG.sync.read(Buffer.from(buffer)); let count = 0; for (let i = 0; i < png.data.length; i += 4) { if (png.data[i] < 250 || png.data[i + 1] < 250 || png.data[i + 2] < 250) count++; } return (100 * count) / (png.width * png.height); }
Add a check like this to your pipeline and fail the job when the value is near zero. A blank image that ships silently is worse than an error.
Results
| Test page | Capture method | Non-white pixels |
|---|---|---|
/spa: content injected by JS after 1.5 s |
goto() then screenshot() |
0.0% |
/spa |
goto(..., { waitUntil: 'networkidle0' }) |
0.0% |
/spa |
waitForSelector('main') |
79.7% |
/fade: body starts at opacity: 0, CSS animation to 1 |
goto() then screenshot() |
0.0% |
/fade |
waitForFunction until opacity is '1' |
25.5% |
/fade |
wait for document.getAnimations() to finish |
25.5% |
/vh: content scrolls inside a 100%-height div |
screenshot({ fullPage: true }) |
image only 800×600 |
/vh |
override height and overflow, then fullPage |
image 800×3480 |
25.5% is the correct value for the fade page: it is a red heading on a white body. The point is the jump from zero.
Cause 1: the content is rendered by JavaScript
A single-page app ships an empty <div id="root"> and builds the page later. goto() resolves on load, which can fire before the framework has rendered anything. Our /spa page used a timer, not a fetch, so even networkidle0 returned with an empty root. Real apps behave the same when they render from cached data or hydrate after a delay.
Fix: wait for an element that only exists after render, with visible: true so a zero-size or hidden placeholder does not count. Our post on screenshots of single-page applications covers routers and hydration in more depth.
Cause 2: entry animations
Landing pages often start sections at opacity: 0 and animate them in. A capture taken during the first frames is white. document.getAnimations() returns CSS animations, CSS transitions and Web Animations; awaiting each .finished promise handled the /fade page without a fixed sleep. Infinite animations never finish, so guard the wait with a timeout, or set prefers-reduced-motion with page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: 'reduce' }]) on sites that honour it.
Cause 3: web fonts that are still loading
With font-display: block, browsers hide text until the font arrives. A page made mostly of text on a white background then captures as nearly blank. await page.evaluate(() => document.fonts.ready) waits for the fonts the page has requested. We did not build a slow-font test page for this post, so this case has no row in the results table. If fonts are missing on a Linux server rather than slow, the text shows as boxes instead; that is a different fix, covered in consistent fonts in headless rendering.
Cause 4: the page scrolls inside a container
Not blank, but reported the same way: "fullPage only shows the top". When html and body are fixed at height: 100% and an inner element scrolls, the document is only as tall as the viewport. Puppeteer measures the document. We fixed the /vh page by adding CSS before the capture:
await page.addStyleTag({ content: 'html,body,#scroller{height:auto!important;overflow:visible!important}', }); await page.screenshot({ path: 'full.png', fullPage: true });
Change #scroller to the scrolling element on your page. The full-page screenshot guide covers lazy images and sticky headers, which cause the next set of gaps.
Cause 5: you captured the wrong page or color scheme
Two cases we hit while testing. A newPage() that has not navigated yet is about:blank. On our macOS machine with system dark mode on, that captured as near-black (RGB 18,18,18), not white. And example.com sets color-scheme: light dark, so it rendered with a dark background on the same machine. If a capture is "blank" in an unexpected color, check page.url() and set the scheme explicitly with page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'light' }]). With that line, example.com captured with its light #eee background on the same machine.
The fix: one wait sequence
This is the script we ran. It worked on example.com and on the /spa test page with main as the selector.
import puppeteer from 'puppeteer'; const url = process.argv[2] ?? 'https://example.com'; const readySelector = process.argv[3] ?? 'p'; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.goto(url, { waitUntil: 'load' }); // 1. The content you came for is in the DOM and visible. await page.waitForSelector(readySelector, { visible: true }); // 2. Web fonts are loaded, so text is not invisible or swapped mid-capture. await page.evaluate(() => document.fonts.ready); // 3. Entry animations and transitions have finished. await page.evaluate(() => Promise.all(document.getAnimations().map((a) => a.finished.catch(() => {}))) ); // 4. Two animation frames, so the last style change is painted. await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); await page.screenshot({ path: 'page.png' }); console.log('saved page.png'); } catch (err) { console.error(err); process.exitCode = 1; } finally { await browser.close(); }
If waitForSelector times out, the page never produced the content. That is a better failure than a white PNG. For navigation that never completes at all, see navigation timeout of 30000 ms exceeded.
Symptom, cause, fix
| Symptom | Likely cause | Fix |
|---|---|---|
| White image, correct size | JS renders after load |
waitForSelector(sel, { visible: true }) |
White even with networkidle0 |
Render from timer or cache, no request | Wait for an element, not the network |
| Faint or partly white image | Entry animation in progress | Await getAnimations(); reduced motion |
| Layout visible, text missing | Web font still loading | document.fonts.ready |
| Boxes instead of letters | Font not installed on the server | Install fonts in the image |
fullPage stops at one screen |
Inner scrolling container | Override height and overflow |
| Black image | Dark color scheme or about:blank |
Check page.url(); emulate light |
| Blank only in CI | Different viewport or font set | Set viewport; install the same fonts |
Version notes
| Version | What matters here |
|---|---|
| puppeteer 25.x (tested) | waitForSelector with visible, document.getAnimations() and emulateMediaFeatures all worked as shown |
| puppeteer 22+ | page.waitForTimeout() was removed; old answers that add waitForTimeout(3000) before the capture now throw |
| puppeteer 22+ | headless: true uses the new headless mode, which renders like headed Chrome; old blank-capture advice about headless: 'new' no longer applies |
Only the 25.x row was run for this page. The other rows come from the Puppeteer changelog.
The same capture with one API call
The RenderScreenshot API takes the wait as data instead of code. You name the selector and the event, and the request fails with an error instead of returning an empty image when the selector never appears. With the Node SDK:
import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const image = await client.take( TakeOptions.url('https://example.com') .waitFor('load') .waitForSelector('main') .delay(300) );
delay adds milliseconds after the wait for late animations; the wait parameters list every option. We did not call the live API for this page. Get a free API key to try it on the page that gives you white captures.
Related in this topic
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Puppeteer on AWS Lambda with @sparticuz/chromium Run Puppeteer on AWS Lambda with @sparticuz/chromium: version matching, a container-image handler tested in the Lambda emulator, and warm reuse.
- Puppeteer in Docker: A Tested Dockerfile with Fonts A non-root Puppeteer Dockerfile with Chromium, CJK and emoji fonts and dumb-init, plus the sandbox error it hits, measured image sizes and launch times.
- Puppeteer Full Page Screenshot: Limits, Lazy Images, Fixes Puppeteer full page screenshot guide: the 16,384 px limit we measured, captureBeyondViewport, lazy-load scrolling, sticky headers and a tested script.
- Hide Cookie Banners and Chat Widgets in Puppeteer Remove cookie banners, consent iframes, shadow-DOM dialogs and chat widgets from Puppeteer screenshots: a tested module and what each method misses.
- Puppeteer HTML to PDF: page.pdf() Options Tested Generate PDFs with Puppeteer page.pdf(): paper size, margins, header and footer templates, page breaks and print CSS, with measured sizes and timings.