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.
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 see | Go to |
|---|---|
| Completely white or only a spinner | Fix 1, then Fix 9 |
| Layout is fine but images are grey boxes | Fix 2 |
| Text in Times New Roman or squares instead of letters | Fix 3 |
fullPage: true returns one screen | Fix 4 |
| Image is 800×600 and the mobile menu is showing | Fix 5 |
| Text is soft or pixelated | Fix 6 |
| Element screenshot is empty, wrong or throws | Fix 7 |
TypeScript error on toString("base64"), or base64 that looks like numbers | Fix 8 |
| "Access denied", captcha or an empty body only on the server | Fix 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.
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.
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.
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:
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:
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:
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:
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:
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:
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: trueand 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:
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
| Option | Default | Notes |
|---|---|---|
path | none | Format inferred from the extension. Relative to the working directory. |
type | png | png, jpeg, webp. |
quality | none | 0 to 100, JPEG and WebP only. |
fullPage | false | Whole document height. |
clip | none | Region { x, y, width, height }. |
captureBeyondViewport | false without clip, true with clip | Captures outside the viewport without resizing it. |
omitBackground | false | Transparent background. |
encoding | binary | base64 returns a string. |
fromSurface | true | Capture from the surface rather than the view. |
optimizeForSpeed | false | Faster 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:
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.