Guides · 2026-10-17
How to take a full-page screenshot with Puppeteer or Playwright (and fix the 6 common problems)
Taking a full-page screenshot in headless Chrome is a one-liner. Getting a screenshot that actually looks like the page — images loaded, no cookie wall, right fonts, no header pasted halfway down — is not. Here's the one-liner for both libraries, followed by the six problems you'll hit in production and how to fix each.
The basics
Puppeteer (Node.js):
import puppeteer from "puppeteer";
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto("https://example.com", { waitUntil: "networkidle2" });
await page.screenshot({ path: "page.png", fullPage: true });
await browser.close();
Playwright (Node.js — the Python API is identical in snake_case):
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto("https://example.com", { waitUntil: "networkidle" });
await page.screenshot({ path: "page.png", fullPage: true });
await browser.close();
fullPage: true measures the document's scroll height and captures all of it, not just the viewport. Now the problems.
1. Lazy-loaded images are blank
Most sites use loading="lazy" or an IntersectionObserver, so images below the fold never load: nothing ever scrolled them into view. Scroll the page in steps before capturing, then return to the top:
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight / 2) {
window.scrollTo(0, y);
await new Promise(r => setTimeout(r, 120));
}
window.scrollTo(0, 0);
});
await page.waitForLoadState?.("networkidle"); // Playwright; in Puppeteer use page.waitForNetworkIdle()
For native loading="lazy" images you can also force them eagerly: document.querySelectorAll('img[loading=lazy]').forEach(i => i.loading = 'eager').
2. Cookie banners cover the page
Clicking "Accept" differs per site, so it's more robust to hide the common consent frameworks with CSS before capturing:
await page.addStyleTag({ content: `
#onetrust-consent-sdk, #CybotCookiebotDialog, #didomi-host, .qc-cmp2-container,
#usercentrics-root, .fc-consent-root, div[id^="sp_message_container"], .cc-window
{ display: none !important; }
html, body { overflow: auto !important; } /* many banners lock scrolling */
`});
The overflow line matters: consent walls often set overflow: hidden on body, which makes fullPage capture only one screen.
3. Fonts and emoji render as boxes
Fresh Linux servers and Docker images ship almost no fonts. Web fonts load fine, but anything relying on system fonts — emoji especially — falls back to tofu boxes. On Debian/Ubuntu:
apt-get install -y fonts-liberation fonts-noto-color-emoji fonts-noto-cjk
Also wait for web fonts explicitly: await page.evaluate(() => document.fonts.ready).
4. Sticky headers repeat or float mid-page
For full-page captures Chrome expands the viewport, but position: fixed and sticky elements can end up in odd places, and some sites re-render the header on scroll. Pin them back into the document flow:
await page.addStyleTag({ content: `
header, nav, [class*="sticky"], [class*="fixed"] { position: absolute !important; }
`});
This is a blunt instrument — test it on your targets — but it fixes the majority of cases.
5. Animations are captured mid-flight
Fade-ins and carousels produce half-transparent or mid-slide screenshots. Disable animations and transitions globally:
await page.addStyleTag({ content: `*, *::before, *::after {
animation-duration: 0s !important; animation-delay: 0s !important;
transition: none !important; scroll-behavior: auto !important; }` });
Playwright also has a built-in option: page.screenshot({ animations: "disabled" }).
6. networkidle never fires
Pages with analytics beacons, long-polling or chat widgets never go network-idle, so goto times out. Wait for load instead, then add a short fixed delay, and block trackers outright — it's faster too:
await page.route("**/*", route => {
const host = new URL(route.request().url()).hostname;
return /doubleclick|googletagmanager|google-analytics|hotjar|facebook\.net/.test(host)
? route.abort() : route.continue();
});
Bonus: don't forget security
If the URL comes from a user, your screenshot server can be tricked into fetching internal addresses like cloud metadata endpoints — and URL validation alone won't stop it. We wrote up how redirects bypass URL checks and how to block it properly.
Or let someone else run Chrome
All of the above — plus browser crash recovery, concurrency limits and memory leaks — is what a screenshot API handles for you. With Shotlane the whole thing becomes one request:
curl "https://shotlane.dev/v1/screenshot?url=example.com&full_page=true&hide_cookie_banners=true&block_ads=true" \ -H "X-API-Key: $SHOTLANE_KEY" -o page.png
Try it without signing up using the free website screenshot tool.
Skip the browser babysitting
Shotlane runs hardened headless Chromium for you: screenshots and PDFs from one HTTP request, with cookie-banner hiding, ad blocking, signed embed URLs and free cache hits. Prepaid credits that never expire.
100 free renders · no credit card · key shown instantly