Skip to main content

Confirm

Are you sure?

Guide

How to screenshot a website programmatically

A viewport screenshot is three lines of Puppeteer. A full-page screenshot that looks like the page is where the work starts. This guide compares the three ways to do it and walks through every gotcha.

By · Last updated: September 2026

TL;DR

To screenshot a website from code, drive a headless browser with Puppeteer or Playwright, or call a screenshot API. Your own browser gives full control and costs you the infrastructure; an API is one HTTP call. Either way, full-page captures need work: scroll to trigger lazy images, deal with sticky headers and cookie banners, and stay under Chrome's 16,384 px and WebP's 16,383 px limits.

Free plan, no credit card. 1,000 credits a month.

The options

Three ways to screenshot a website from code

Every approach ends with a real browser rendering the page and encoding what it drew. The difference is whose browser it is, and who deals with it when it misbehaves.

PuppeteerPlaywrightA screenshot API
What it isNode library driving Chrome, and Firefox over WebDriver BiDiLibrary for Node, Python, Java and .NET driving Chromium, Firefox and WebKitAn HTTP endpoint: send a URL, get an image
Runs whereYour machine or serverYour machine or serverThe provider's browsers
ControlTotalTotalWhat the API exposes
You maintainChrome binaries, memory, crashes, fonts, scalingSame, times three enginesAn API key
CostYour servers and your timeYour servers and your timePer screenshot
Best forChrome-only jobs in a Node stackCross-browser checks, tests, non-Node stacksScreenshots as a feature, not as infrastructure

Between the two libraries, for plain screenshots, the choice matters less than it seems: the code is almost the same and both drive the same Chrome. Pick Playwright if you need Safari's engine or already use it for tests; pick Puppeteer if you want the smaller dependency maintained by the Chrome team. The real decision is whether to run browsers at all.

The easy part

A viewport screenshot in a few lines

Both libraries take a screenshot of the visible viewport by default, and a full-page one with a flag. This is the code every tutorial shows — and it's correct, for pages that behave.

Puppeteer
import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1350, height: 800 });
await page.goto("https://example.com", { waitUntil: "networkidle2", timeout: 30_000 });
await page.screenshot({ path: "page.png", fullPage: true });
await browser.close();
Playwright
import { chromium } from "playwright";

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1350, height: 800 } });
await page.goto("https://example.com", { waitUntil: "load", timeout: 30_000 });
await page.screenshot({ path: "page.png", fullPage: true });
await browser.close();

Run it against a real marketing page, though, and you'll meet the problems below within a day. None of them is a bug in the library; they're the page doing what it was built to do for a human scrolling through it.

The hard part

Full-page gotchas, and how to handle each

Lazy-loaded images come out blank

Most images below the fold use loading="lazy" or an IntersectionObserver: they only load when scrolled into view. A full-page capture doesn't scroll, so they're grey boxes or missing. Scroll through the page first, then wait for the images to finish.

Scroll to trigger lazy loading, then wait for images
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, 150));
  }
  window.scrollTo(0, 0);
  await Promise.all([...document.images].filter((img) => !img.complete)
    .map((img) => new Promise((r) => { img.onload = img.onerror = r; })));
  await document.fonts.ready;
});

Sticky headers, and things sized to the screen

A fixed or sticky header is fine in a single-pass capture, but any tool that stitches a tall image from several scroll positions draws it once per tile. And some full-page methods work by resizing the viewport to the page's full height, which makes anything sized in vh — a hero set to 100vh — grow to thousands of pixels. If you see either, inject CSS before capturing: position: static on the header, a fixed height on the hero.

Cookie banners cover the page — and lock it

A headless browser is always a first-time visitor, so it always gets the consent banner. Worse, many consent managers set overflow: hidden on html or body while the banner is open, and a full-page capture of a page that can't scroll is exactly one viewport tall. Remove the banner's elements and restore overflow; the guide on cookie banners and ads covers how without clicking "accept".

Very tall pages come back truncated or empty

Chrome renders at most 16,384 device pixels in one surface. Past that, the capture doesn't fail — it comes back cut off, or empty. At a device scale factor of 2 that ceiling is 8,192 CSS pixels, which a long landing page passes easily. WebP is stricter still: the format stores each dimension in 14 bits, so 16,383 pixels is the most it can describe, and Chrome returns an empty string rather than an error past it. Clamp the height yourself, and check the output isn't empty.

"Loaded" isn't finished

networkidle waits for the network to go quiet, which some pages never do — analytics beacons, long-polling, video. load fires before a single-page app has fetched its data. The reliable signal is the element you care about: wait for its selector, then capture. Always set a timeout, and treat hitting it as a failure rather than screenshotting a spinner.

Running it

What running your own browsers costs

The library is free. The browser isn't. Each Chrome instance wants hundreds of megabytes of memory and a CPU core while it renders; a heavy page can take several seconds and much more. At any volume you need a pool, a queue, timeouts that actually kill hung tabs, restarts for leaked processes, fonts installed for every script you'll meet, and a plan for pages that fight headless browsers.

That work is worth doing when screenshots are your product, when you need a browser you control end to end, or when volume is high and steady enough to amortize it. It is usually not worth doing when screenshots are one feature among many — which is what screenshot APIs are for.

  • Run your own if you need logged-in sessions, custom browser flags, other engines, or millions of captures a month with someone to own the fleet.
  • Use an API if you want an image back from a URL and would rather not be paged when Chrome leaks memory at 3 a.m.

With URLpipe

The same screenshot as one HTTP call

URLpipe's /screenshot endpoint handles the gotchas above by default. It captures the full page unless you ask otherwise, scrolls through it first so lazy images are in the picture, uses a 1350 × 797 desktop window, and clamps tall pages to 16,384 pixels (one less for WebP). It costs 1 credit whatever options you use.

The options live in screenshot_options: viewport from 320 to 1920 wide, 1–3× scale, one element by selector, PNG, JPEG or WebP with a quality setting, dark mode, elements to hide and CSS to inject. What the page itself should wait for or lose — ads, cookie banners, a selector that appears late — goes in page_options. The response is the image in Base64, with an X-Result-Url header linking to it without a key for 30 days.

POST /screenshot
curl -X POST https://urlpipe.dev/screenshot \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/pricing",
    "sync": true,
    "screenshot_options": { "viewport_width": 390, "device_scale_factor": 2, "format": "webp" },
    "page_options": { "block_cookie_banners": true, "block_ads": true }
  }' | base64 --decode > pricing.webp

What it doesn't do: log in, click through a flow before capturing, or run browsers other than Chrome. For those, Playwright is the right tool.

FAQ

Frequently asked questions

Should I use Puppeteer or Playwright for screenshots?
For Chrome only, either works and the code is nearly identical. Playwright adds Firefox and WebKit, auto-waiting and a test runner; Puppeteer is smaller and maintained by the Chrome team. Pick the one your team already knows.
Why are images missing from my full-page screenshot?
They are lazy-loaded: the page only fetches an image when it scrolls into view, and a full-page capture doesn't scroll. Scroll through the page first, wait for the images to load, then capture.
Why does my full-page screenshot have the header repeated?
A sticky or fixed header is drawn once per viewport-sized tile when the capture is stitched from several scroll positions. Capture in one pass, or switch the header to position: static with injected CSS before capturing.
How tall can a screenshot be?
Chrome renders at most 16,384 device pixels in one surface — half that in CSS pixels at 2x — and the WebP format cannot store a dimension above 16,383 px. Past those, captures come back truncated or empty rather than failing, so clamp the height yourself.
Is it cheaper to run my own headless Chrome?
At low volume, a hosted API is usually cheaper once you count the server, the memory each browser needs and the time spent keeping it running. At high, steady volume your own fleet can win — if you have someone to run it.

Put this into practice.

Each of the eight kinds of data URLpipe returns has a free, no-signup tool — try the ideas from this guide on a real page, then grab an API key to run them from your code. 1,000 credits a month, no card.