Why your Puppeteer and Playwright screenshots come out blank or white
"Blank screenshot" isn't one problem — it's at least five different ones. Compare your image against five visual patterns to find the right cause and the right fix.
"Blank screenshot" isn't one problem — it's several different ones. Which one you have determines the fix. Before you start editing code, it's worth comparing your image against the descriptions below to figure out which case you're in.
Try classifying your symptom:
Completely white or gray frame, no signs of content — usually the viewport got captured before the page actually painted
Content is partially visible, but the background is missing or transparent — a conflict between a transparent
bodyand theomitBackgroundoptionMost of the page is visible, but a white stripe runs through the entire height —
position: fixedelements break full-page renderingOne specific block is white or black (chart, 3D view, video player) — Canvas or WebGL didn't preserve its buffer by the time of the screenshot
The first viewport has no images, everything below is fine —
loading="lazy"didn't fire inside the viewport
Below are the five causes in order, with fixes. For each one, I'll show both Puppeteer and Playwright.
Cause 1: the viewport was captured before the page actually rendered
This is the most common case, and the source of the truly "completely white frame." You call goto, wait for some loading event, take the screenshot — and you get a clean blank sheet. If you open the same site by hand, everything works; the problem only shows up under automated capture.
This happens because load or even networkidle0 fire earlier than the JavaScript framework manages to render the first frame. It hits especially hard on React, Vue, Next.js, and other SPAs — they show an empty <div id="root"> while the HTML is loading, and only paint the real UI several phases later. I went deeper into this scenario, with all its pitfalls, in my separate post on screenshots of single-page applications.
The basic fix is to wait for a specific selector rather than a general page state:
// Puppeteer
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('h1, [data-loaded]', { timeout: 15000 });
await page.screenshot();
// Playwright
await page.goto(url);
await page.waitForSelector('h1, [data-loaded]', { timeout: 15000 });
await page.screenshot();The selector h1, [data-loaded] means "wait for either the first heading or a dedicated readiness attribute." This works on almost any page, because every meaningful page has a heading that only appears after rendering. If the target site doesn't have an obvious selector — you can fall back to a fixed delay of 1-2 seconds, but that's less reliable. I have a deeper post on different waiting strategies.
Cause 2: a transparent body and the omitBackground option
This case is less obvious and often catches people off guard. You take a screenshot with omitBackground: true (default is false, but a lot of people enable it for transparent PNGs), and you get either a white frame or an image where pixels "show through" unpredictably.
The logic is this: omitBackground tells the browser "don't paint the page's default white background." It works correctly only when body or html have an explicitly transparent background set through CSS. On most sites, the background is either a specific color or inherited from the theme — and then omitBackground either doesn't behave as expected, or produces artifacts in the final PNG.
A simple way to confirm whether this is your case is to open DevTools, select <body>, and check its computed background-color. If you see something like rgb(255, 255, 255) or rgb(0, 0, 0) while you're using omitBackground: true, that's your cause.
The fix depends on what you actually need:
// If you want a regular screenshot with a background — just remove omitBackground
await page.screenshot({ path: 'screenshot.png' });
// If you really need a transparent PNG — make body transparent through CSS first
await page.addStyleTag({
content: 'html, body { background: transparent !important; }'
});
await page.screenshot({ omitBackground: true, path: 'screenshot.png' });I tend to use omitBackground only for screenshots of specific elements (preview cards, for example), where I have explicit control over the CSS of that element. For full-page screenshots, it creates more problems than it solves.
Cause 3: position: fixed breaks full-page screenshots
The symptom for this one is very recognizable — most of the page is visible on the screenshot, but a white or colored horizontal stripe runs through its entire height. Often it's a stripe with a shadow, 60-80 pixels wide. If you look closely, you can see the same UI element (cookie banner, header, chat widget) repeated multiple times or stretched along the stripe.
This happens because Puppeteer and Playwright take full-page screenshots by scrolling — they grab the viewport, scroll, grab the next one, stitch them together. Any element with position: fixed stays put on screen during scrolling, so it lands in every captured frame, and in the final image the element ends up "smeared" through the entire height.
The most common offenders: cookie consent banners, chat widgets like Intercom and Drift, sticky headers, "back to top" buttons. The universal fix is to hide all fixed elements through CSS before the screenshot:
// Puppeteer / Playwright (identical)
await page.goto(url);
await page.addStyleTag({
content: `
[class*="cookie"],
[class*="banner"],
[id*="intercom"],
[class*="sticky"],
*[style*="position: fixed"],
*[style*="position:fixed"] {
display: none !important;
}
`
});
await page.screenshot({ fullPage: true });Selectors here need to be tuned per site — on one it'll be .cookie-notice, on another #gdpr-banner, on a third [data-testid="chat-widget"]. If you're screenshotting a variety of sites, it makes sense to put together a shared blocklist of common classes and IDs. I have a separate post on hiding cookie banners, ads, and chat widgets — there's a list of concrete selectors there that covers most cases.
Cause 4: Canvas or WebGL returns an empty frame
The symptom for this one is recognizable too — most of the page renders normally, but a specific block (a chart, a 3D visualization, a video player with custom controls, an interactive map) shows up as a white or black rectangle with the right dimensions but no content.
The reason has to do with how Chrome handles GPU buffers. By default, after Canvas or WebGL has rendered a frame, the buffer's contents may get cleared as a memory optimization. When you take the screenshot, the browser tries to read that buffer, and it's already empty. This shows up especially often with Three.js, Plotly, mapbox-gl, and any custom WebGL visualization.
The solution depends on whether you control the site or not. If you do — add preserveDrawingBuffer: true when initializing the WebGL context:
// On the site side (Three.js example)
const renderer = new THREE.WebGLRenderer({
preserveDrawingBuffer: true
});
// For plain WebGL
const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true });If you don't control the site, there's a different fix — force Canvas to repaint right before the screenshot:
// Puppeteer / Playwright
await page.goto(url);
await page.waitForSelector('canvas');
// Wait for WebGL/Canvas to actually paint
await page.evaluate(() => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
});
await page.screenshot();The double requestAnimationFrame is needed because the first rAF guarantees you've waited for the next render frame, and the second one ensures that frame actually painted into the buffer. It's a heuristic, not perfect, but works in most cases. For the most stubborn WebGL scenes, you sometimes need to add an explicit setTimeout(resolve, 500) after rAF.
Cause 5: lazy loading inside the first viewport
The final case — the first viewport of the page looks "sparse": text blocks and layout are present, but images are missing, with empty space or gray placeholders where they should be. Content below the first viewport may look fine or also empty, depending on viewport size.
Modern sites use the loading="lazy" attribute or intersection-based lazy loading to fetch images only when they come into the visible area. On a standard viewport (1280×720, say), that means part of the hero section is already off-screen and didn't load. On very large viewports (1920×4000 for a full-page screenshot), the problem becomes more noticeable — the first viewport is real, but everything below loads "on demand."
The solution is to scroll the page all the way down before taking the screenshot, wait for all images to load, then come back to the top:
// Puppeteer / Playwright (identical)
await page.goto(url, { waitUntil: 'domcontentloaded' });
// Scroll down to trigger lazy loading
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 100;
const timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= document.body.scrollHeight) {
clearInterval(timer);
resolve();
}
}, 100);
});
});
// Back to the top
await page.evaluate(() => window.scrollTo(0, 0));
await new Promise((r) => setTimeout(r, 1000));
await page.screenshot({ fullPage: true });I covered this case in a lot more detail — with all the variants of loading="lazy", infinite scroll, and edge cases — in my separate post on lazy loading and blank images. It also has more advanced versions of this script.
How to diagnose this faster
When I hit a blank screenshot in new code, I follow this order. First, I look at the screenshot itself — what kind of "blank" do I have? That immediately determines the debugging branch.
If the frame is completely white, with no signs of page structure — it's almost always Cause 1, a waiting problem. Disable any omitBackground, set up waitForSelector on real content.
If content is visible but the background looks strange, transparent, or has artifacts — Cause 2; check omitBackground and the computed body background.
If a horizontal stripe runs across the page — Cause 3; hunt for fixed elements and kill them through CSS injection.
If one specific block is empty while the page around it is normal — it's either Cause 4 (Canvas/WebGL) or a specific iframe. Look at the element type in DevTools: <canvas> or <iframe>.
If the first viewport has no images — Cause 5; add auto-scroll before the screenshot.
This classification takes about 30 seconds and narrows the search down to one of the five causes immediately.
If you'd rather not maintain this yourself
I built screenshotrun precisely because this whole set of symptoms reappears in every project. The API has reasonable defaults set up for each of these causes — auto-scroll for lazy loading, hide-fixed-elements for cookie banners, stable-DOM waiting for SPAs, double-rAF for Canvas. You can override the parameters through the query, but most blank cases are gone out of the box:
const params = new URLSearchParams({
url: 'https://example.com',
full_page: 'true',
block_cookies: 'true',
});
const response = await fetch(
`https://screenshotrun.com/api/v1/screenshots/capture?${params}`,
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
const buffer = await response.arrayBuffer();When you need to take hundreds of screenshots of different sites every day, and there's no resource for writing an individual scenario for each one, this approach saves a lot of time.
Blank screenshot is a deceptive symptom. It looks like "one problem," but at least five different causes hide behind it, and each gets solved its own way. The good news is that classifying it visually takes a few seconds and points you straight to the right branch — after which the fix becomes mechanical.
Vitalii Holben