To take a Puppeteer screenshot, launch a browser, open the page and call page.screenshot({ path: "page.png" }). Add fullPage: true for the whole scrollable page, or call screenshot() on an element handle for one element. Puppeteer is a JavaScript library that controls Chrome or Firefox over the DevTools Protocol or WebDriver BiDi.
The examples below were run with Puppeteer 25.16 and Chrome 156 on Node.js 26, against example.com and a local test page with lazy-loaded images, a cookie banner and a chat widget.
Install Puppeteer
Puppeteer 25 needs Node.js 22.12 or later.
npm install puppeteer
npx puppeteer browsers install # downloads Chrome for Testing if the install didn't
npm install puppeteer used to download a matching Chrome in an install script. Newer npm versions, pnpm, Yarn Berry and Bun block dependency install scripts by default, so the browser can be missing and puppeteer.launch() fails. The second command installs it either way, into ~/.cache/puppeteer. Already have Chrome? Use puppeteer-core and pass executablePath to launch().
1. Take your first screenshot
// screenshot.mjs
import puppeteer from "puppeteer";
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto("https://example.com", { waitUntil: "networkidle2" });
await page.screenshot({ path: "example.png" });
await page.screenshot({ path: "example-full.png", fullPage: true });
} finally {
await browser.close();
}
The default viewport is 800 × 600, so set one. waitUntil decides when goto returns:
waitUntil | Returns when |
|---|---|
"load" (default) | The load event fired: HTML, styles and images loaded |
"domcontentloaded" | The HTML is parsed; images may still be missing |
"networkidle2" | No more than 2 network connections for 500 ms; good for pages that keep a connection open |
"networkidle0" | No network connections for 500 ms |
try/finally matters as soon as this runs in a loop or a server: a page that throws must not leave a Chrome process behind.
2. Full-page screenshots with lazy-loaded images
fullPage: true captures the page as it is at that moment. On our test page, a plain full-page screenshot showed 3 of 5 native lazy images (loading="lazy"), none of the 5 images that load by script when scrolled into view, and the fixed cookie banner in the middle of the image. This version scrolls first, waits for images and fonts, and hides the banner and the chat bubble:
// full-page.mjs
import puppeteer from "puppeteer";
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto("https://example.com", { waitUntil: "networkidle2" });
// Scroll one screen at a time so lazy images start loading, then back to the top
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
window.scrollTo(0, y);
await new Promise((resolve) => setTimeout(resolve, 100));
}
window.scrollTo(0, 0);
});
await page.waitForFunction(() => [...document.images].every((img) => img.complete));
await page.evaluate(() => document.fonts.ready); // web fonts, not fallback fonts
// Hide what should not be in the picture
await page.addStyleTag({
content: "#cookie-banner, .chat-launcher { display: none !important; }",
});
await page.screenshot({ path: "full.png", fullPage: true });
} finally {
await browser.close();
}
With this script, all ten images loaded and the banner was gone. The selectors are those of our test page; every site names its cookie banner and chat widget differently, and the banner can also differ by country.
3. Formats, regions and retina
await page.screenshot({ path: "page.jpg", type: "jpeg", quality: 80 });
await page.screenshot({ path: "page.webp", type: "webp", quality: 80 });
await page.screenshot({ path: "top.png", clip: { x: 0, y: 0, width: 1280, height: 400 } });
await page.screenshot({ path: "transparent.png", omitBackground: true });
const png = await page.screenshot(); // a Uint8Array, for uploading instead of saving
const base64 = await page.screenshot({ encoding: "base64" }); // for a data: URL or JSON
| Option | What it does |
|---|---|
path | File to write; the extension sets the format if type is missing |
type, quality | "png" (default), "jpeg" or "webp"; quality 0–100 for JPEG and WebP |
fullPage | The whole scrollable page |
clip | A region { x, y, width, height } |
omitBackground | Transparent where the page has no background of its own |
encoding | "binary" (default, a Uint8Array) or "base64" |
optimizeForSpeed | Faster encoding, larger files |
For a retina screenshot, raise the device scale factor; the layout stays the same and the image gets twice as many pixels in each direction:
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 });
await page.screenshot({ path: "retina.png" }); // 2560 × 1600 pixels
4. Screenshot one element
const pricing = await page.waitForSelector("#pricing");
await pricing.screenshot({ path: "pricing.png" });
waitForSelector waits until the element exists; screenshot() on the handle scrolls it into view and crops to its box. With deviceScaleFactor: 2 still set from the previous step, our 1280 × 581 pricing section came out at 2560 × 1162 pixels.
5. Mobile devices and dark mode
KnownDevices holds the viewport, device scale factor, touch support and user agent of common phones and tablets:
// mobile.mjs
import puppeteer, { KnownDevices } from "puppeteer";
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.emulate(KnownDevices["iPhone 15 Pro"]); // viewport, scale factor, touch, user agent
await page.goto("https://example.com", { waitUntil: "networkidle2" });
await page.screenshot({ path: "iphone-15-pro.png" });
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "dark" }]);
await page.screenshot({ path: "iphone-15-pro-dark.png" });
} finally {
await browser.close();
}
The iPhone 15 Pro screenshot is 1179 × 1977 pixels: a 393 × 659 viewport at scale 3. It’s still Chrome’s engine with an iPhone’s size and user agent, not Safari; for WebKit rendering, use Playwright. The dimensions of common devices are on our viewport sizes pages.
Set the color scheme explicitly in both directions. Headless Chrome follows the operating system: on our test Mac, which runs in dark mode, every screenshot without emulateMediaFeatures came out dark. { name: "prefers-color-scheme", value: "light" } pins it to light.
6. Run Puppeteer in production
A script on your laptop is one thing; screenshots as part of a service are another. What breaks first:
| Problem | What happens | What helps |
|---|---|---|
| Memory | Each page needs tens to hundreds of MB; pages that are never closed add up until the server swaps or the process is killed | Close every page in finally, limit open pages, restart the browser now and then |
| Crashes and hangs | Chrome can crash or a page can hang, and the next request fails too | Timeouts on goto, a health check, browser.on("disconnected") to relaunch |
| Fonts and emoji | Linux servers lack fonts, so pages render with fallback fonts and empty boxes | Install font packages (e.g. Noto and Noto Color Emoji) in your image |
| Bot detection | The default user agent says HeadlessChrome; many sites block or challenge it | Set a regular user agent; some sites still block data-center IPs |
| Cookie banners and ads | Different per site and per country | Per-site CSS or clicks, maintained by you |
| Untrusted URLs | Users can point your service at internal addresses | Allow only public URLs; block private networks |
A minimal service that reuses one browser, opens a page per request and caps the open pages:
// screenshot-service.mjs — one browser, a new page per request, at most 4 at a time
import http from "node:http";
import puppeteer from "puppeteer";
const browser = await puppeteer.launch();
const MAX_PAGES = 4;
let open = 0;
async function screenshot(url) {
const page = await browser.newPage();
try {
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
return await page.screenshot({ type: "png" });
} finally {
await page.close(); // always, or every failed page keeps its memory
}
}
http
.createServer(async (req, res) => {
const target = new URL(req.url, "http://localhost").searchParams.get("url");
if (!target?.startsWith("https://")) return res.writeHead(400).end("url must be https://");
if (open >= MAX_PAGES) return res.writeHead(503, { "Retry-After": "1" }).end("busy");
open++;
try {
const png = await screenshot(target);
res.writeHead(200, { "Content-Type": "image/png" }).end(png);
} catch (err) {
res.writeHead(502).end(String(err.message));
} finally {
open--;
}
})
.listen(8080);
In our test, six parallel requests got four screenshots and two 503 answers, and an unreachable host returned a 502 with Chrome’s network error. The https:// check is not enough protection on its own: before you expose such a service, also refuse host names that resolve to private or internal addresses.
7. Or call a screenshot API instead
A screenshot API runs the browsers, fonts, queues and banner blocking for you. With screenshotbase, the full-page screenshot from section 2 is one request:
curl -G https://api.screenshotbase.com/v1/take \
-H "apikey: $SCREENSHOTBASE_KEY" \
--data-urlencode "url=https://example.com" \
-d full_page=1 \
-d dark_mode=1 \
-d block_cookie_banners=1 \
-o example.png
The same from Node.js, without Chrome on your side:
// api-screenshot.mjs — no browser on your side
import { writeFile } from "node:fs/promises";
const params = new URLSearchParams({
url: "https://example.com",
full_page: "1", // scrolls first, so lazy images load
viewport_width: "1280",
block_cookie_banners: "1",
format: "webp",
});
const res = await fetch(`https://api.screenshotbase.com/v1/take?${params}`, {
headers: { apikey: process.env.SCREENSHOTBASE_KEY },
signal: AbortSignal.timeout(120_000), // a render may take up to 110 seconds
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`screenshotbase ${res.status} ${body.error?.code}: ${body.message}`);
}
await writeFile("example.webp", Buffer.from(await res.arrayBuffer()));
| Puppeteer | screenshotbase /v1/take |
|---|---|
page.screenshot({ fullPage: true }) | full_page=1, which scrolls first and draws bottom bars at the end |
page.setViewport({ width, height, deviceScaleFactor }) | viewport_width, viewport_height, device_scale_factor |
page.emulate(KnownDevices["iPhone 15 Pro"]) | viewport_device=iphone-15-pro |
emulateMediaFeatures (dark) | dark_mode=1 |
elementHandle.screenshot() | selector=#pricing |
clip, type, quality, omitBackground | clip_*, format, quality, omit_background |
addStyleTag | styles, hide_selectors, block_cookie_banners, block_chats, block_ads |
waitUntil, waitForSelector | wait_until, wait_for_selector, delay |
setExtraHTTPHeaders, browser.setCookie, authenticate | headers, cookies, authorization |
--proxy-server | proxy, or ip_country_code for one of 117 countries |
page.pdf() | format=pdf |
page.click, page.type, page.evaluate | actions (up to 20 steps) and scripts |
The API also returns AVIF, SVG and markdown, renders HTML or markdown you send, and runs long renders as async jobs with a webhook. It doesn’t solve CAPTCHAs: a bot challenge page is captured as it is, or fails the request with ignore_host_errors=0. For tests and long multi-step flows behind a login, keep Puppeteer.
The free plan has 300 screenshots a month for testing and personal projects; paid plans start at $15 a month for 3,000 (pricing). The Puppeteer screenshot API alternative page puts both side by side with sources. Try it without code with the website screenshot tool or the mobile screenshot tool.