· Troubleshooting · By Simon Chiu

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