Keyword: puppeteer screenshot not working · Updated October 15, 2026

Puppeteer Screenshot Blank or Cut Off? 9 Fixes That Actually Work

A Puppeteer screenshot that comes back white, half-rendered or cut at 600 pixels is rarely a Puppeteer bug. The browser captured exactly what was on the screen at that moment; the page just was not ready, or it was laid out differently than you assumed. This guide starts with a short diagnostic script, then goes through nine common causes, each with a fix.

Step 0: find out what the browser actually saw

Before changing waits at random, log the HTTP status, console errors, failed requests and page size. Many "blank screenshot" problems turn out to be a 403 page, a JavaScript error or a viewport of 800×600.

import puppeteer from "puppeteer"; const url = process.argv[2] ?? "https://example.com"; const browser = await puppeteer.launch(); const page = await browser.newPage(); page.on("console", (msg) => msg.type() === "error" && console.log("console:", msg.text())); page.on("pageerror", (err) => console.log("pageerror:", err.message)); page.on("requestfailed", (req) => console.log("failed:", req.url(), req.failure()?.errorText)); const response = await page.goto(url, { waitUntil: "load" }); console.log("status:", response?.status()); const info = await page.evaluate(() => ({ title: document.title, viewport: [window.innerWidth, window.innerHeight], docHeight: document.documentElement.scrollHeight, bodyText: document.body.innerText.slice(0, 200), })); console.log(info); await page.screenshot({ path: "debug.png" }); await browser.close();

Run it with node debug.mjs https://your-page. The output usually points straight at one of the fixes below.

Symptom to fix

What you seeGo to
Completely white or only a spinnerFix 1, then Fix 9
Layout is fine but images are grey boxesFix 2
Text in Times New Roman or squares instead of lettersFix 3
fullPage: true returns one screenFix 4
Image is 800×600 and the mobile menu is showingFix 5
Text is soft or pixelatedFix 6
Element screenshot is empty, wrong or throwsFix 7
TypeScript error on toString("base64"), or base64 that looks like numbersFix 8
"Access denied", captcha or an empty body only on the serverFix 9

1. The screenshot runs before the app renders

page.goto() resolves on the load event by default. A React, Vue or Angular app may fire load with an empty root element and fetch its data afterwards. The fix is to wait for something that only exists once the real content is there.

await page.goto(url, { waitUntil: "networkidle2" }); await page.waitForSelector("[data-testid='product-grid'] article", { visible: true, timeout: 15000 }); await page.screenshot({ path: "products.png" });

networkidle2 waits until there are no more than two open connections for 500 ms, which tolerates analytics pings. networkidle0 waits for zero and can hang forever on pages with long polling or websockets. A selector wait is the most reliable signal because it checks the result, not the network.

Avoid a fixed await new Promise(r => setTimeout(r, 5000)) as the main fix. It makes every capture slow and still fails on a slow day. Use it only as a short extra delay after a selector wait, for example to let a chart animation finish.

2. Lazy-loaded images below the fold

Images with loading="lazy" or an IntersectionObserver only load when they come near the viewport. A full-page screenshot does not scroll, so everything below the first screen stays empty. Scroll through the page first, then wait for the images to finish.

async function loadLazyContent(page) { await page.evaluate(async () => { const step = window.innerHeight; for (let y = 0; y < document.documentElement.scrollHeight; y += step) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); } window.scrollTo(0, 0); await Promise.all( [...document.images] .filter((img) => !img.complete) .map((img) => new Promise((r) => { img.onload = img.onerror = r; })) ); }); } await loadLazyContent(page); await page.screenshot({ path: "full.png", fullPage: true });

Scrolling back to the top matters. Sticky headers and "back to top" buttons react to the scroll position, and you want them in their initial state.

3. Fonts are missing or still loading

There are two different problems here. If the text shows in a fallback font, the web fonts have not finished loading. Wait for them:

await page.evaluate(() => document.fonts.ready);

If you see empty squares instead of characters (Chinese, Japanese, Arabic, emoji), the font is missing from the operating system. That happens in slim Docker images. Install font packages in the image:

# Debian / Ubuntu based images RUN apt-get update && apt-get install -y --no-install-recommends \ fonts-liberation fonts-noto-cjk fonts-noto-color-emoji \ && rm -rf /var/lib/apt/lists/*

4. fullPage only captures the first screen

Puppeteer's fullPage measures the document. Dashboards and many SPAs set html, body { height: 100%; overflow: hidden } and scroll inside a main or #app element. The document is one viewport tall, so that is what you get. Let the shell grow before capturing:

await page.addStyleTag({ content: ` html, body, #root, #app, main { height: auto !important; min-height: 0 !important; overflow: visible !important; } `, }); await page.screenshot({ path: "dashboard-full.png", fullPage: true });

Adjust the selector list to the app's actual scroll container. You can find it in DevTools by scrolling and checking which element's scrollTop changes.

On extremely tall pages (tens of thousands of pixels), a single capture can come back with repeated or blank sections because of GPU texture limits. Capture in chunks with clip and captureBeyondViewport: true, or limit the page height you capture.

5. The default 800×600 viewport

Puppeteer opens pages at 800×600. Responsive sites switch to their tablet or mobile layout at that width, so you get a hamburger menu and a single column. Set the viewport before navigating:

const browser = await puppeteer.launch({ defaultViewport: { width: 1440, height: 900 }, }); // or per page: await page.setViewport({ width: 1440, height: 900 });

Pass defaultViewport: null if you want the page to follow the real window size in headed mode. In headless mode, set an explicit size.

6. Blurry or low-quality text

The quality option only controls JPEG and WebP compression, and the docs say it does not apply to PNG. For sharper output, render more pixels:

await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 2 }); await page.screenshot({ path: "retina.png" }); // 2880×1800 // Smaller file, still sharp: await page.screenshot({ path: "retina.webp", type: "webp", quality: 85 });

If you resize the image later (for thumbnails), resize from the 2x version. Downscaling looks clean; upscaling a 1x capture looks soft.

7. Element screenshots that are empty or fail

Get the element with a wait so it exists and is visible, then call screenshot() on the handle:

const card = await page.waitForSelector(".pricing-card.pro", { visible: true }); await card.screenshot({ path: "pro-card.png" });

Common reasons it still goes wrong:

  • The element has zero height because its content is absolutely positioned. Capture a parent instead.
  • A sticky header overlaps the element after Puppeteer scrolls it into view. Hide the header with page.addStyleTag() for the capture.
  • The selector matches a hidden duplicate (for example a mobile and a desktop version of the same card). Use { visible: true } as above.
  • For a transparent PNG of a widget, set omitBackground: true and make sure the page and the element itself have no background color.

8. TypeScript says the result is a Uint8Array

Current Puppeteer types declare page.screenshot() as Promise<Uint8Array>. In Node.js the runtime value is still a Buffer, so old JavaScript keeps working, but TypeScript rejects bytes.toString("base64") with TS2554: Expected 0 arguments. And calling toString() on a plain Uint8Array returns comma-separated numbers, not base64. Be explicit about the encoding:

// Option 1: let Puppeteer encode it const b64 = await page.screenshot({ encoding: "base64" }); // Option 2: wrap the bytes explicitly const bytes = await page.screenshot(); const buffer = Buffer.from(bytes); await fs.promises.writeFile("shot.png", buffer); const dataUrl = `data:image/png;base64,${buffer.toString("base64")}`;

9. The site blocks headless browsers

If the diagnostic script shows status 403 or 429, a captcha title or an almost empty body only when running on a server, the site's bot protection is answering instead of the site. Datacenter IP ranges are often treated more strictly than home connections.

What helps for legitimate use (your own sites, clients' sites, pages you are allowed to capture):

  • Ask the site owner to allowlist your IP or user agent. For your own properties this is the clean solution.
  • Set a realistic viewport and a current user agent string with page.setUserAgent().
  • Check whether a consent page or region redirect is what you are seeing. Accept or reject the banner first, or block the consent script.

Respect the site's terms. Bypassing access controls on sites you have no right to capture is not something a screenshot pipeline should do.

ScreenshotOptions reference

OptionDefaultNotes
pathnoneFormat inferred from the extension. Relative to the working directory.
typepngpng, jpeg, webp.
qualitynone0 to 100, JPEG and WebP only.
fullPagefalseWhole document height.
clipnoneRegion { x, y, width, height }.
captureBeyondViewportfalse without clip, true with clipCaptures outside the viewport without resizing it.
omitBackgroundfalseTransparent background.
encodingbinarybase64 returns a string.
fromSurfacetrueCapture from the surface rather than the view.
optimizeForSpeedfalseFaster encoding at the cost of file size.

When to stop fixing and hand it off

Every fix above is a few lines. Together they become a screenshot service you maintain: wait strategies per site, scroll helpers, font packages in Docker, Chromium crashes under load, memory limits and retries. If screenshots are a feature of your product rather than part of a test suite, that maintenance rarely pays off.

SnapshotFlow runs these steps for each request. The same capture as fixes 1 to 6, as one call:

curl --get "https://api.snapshotflow.com/screenshot" \ -H "X-Api-Key: $SNAPSHOTFLOW_API_KEY" \ --data-urlencode "url=https://example.com/products" \ --data-urlencode "width=1440" \ --data-urlencode "height=900" \ --data-urlencode "device_scale_factor=2" \ --data-urlencode "full_page=true" \ --data-urlencode "wait_until=networkidle2" \ --data-urlencode "wait_for_selector=[data-testid='product-grid'] article" \ --data-urlencode "block_cookie_banners=true" \ --data-urlencode "format=webp" \ -o products.webp

If the selector never appears, the API returns 422 SELECTOR_NOT_FOUND instead of a white image, which is easier to alert on. For a full option-by-option migration, read Puppeteer alternative for screenshots.

FAQ

Why is my Puppeteer screenshot black instead of white?

A black image usually comes from a page with a dark background that has not rendered content yet, or from omitBackground: true saved as JPEG, which has no transparency. Check fix 1 and save transparent captures as PNG or WebP.

Does headless mode change screenshots?

Current Puppeteer uses Chrome's new headless mode by default, which renders like regular Chrome. The old mode is still available as headless: "shell". Differences you see are usually the viewport (fix 5) or bot detection (fix 9).

How do I screenshot a page behind a login?

Set the session cookies with browser.setCookie() (or log in with the script) before page.goto(). Our authenticated page screenshot guide covers cookies and headers in detail.

Why does my screenshot work locally but not in Docker?

Missing fonts (fix 3), a different default viewport, too little shared memory for Chromium (--disable-dev-shm-usage or a larger /dev/shm) and bot protection on datacenter IPs (fix 9) are the usual differences.

Sources and further reading

Stop debugging blank screenshots

SnapshotFlow handles waits, lazy loading, viewport and retina settings for every request, and returns an error instead of a white image when the element you wait for never appears. The free tier gives you 300 screenshots, no credit card required.