· Tutorials · By Simon Chiu

Website Screenshots in Node.js: Puppeteer, Playwright, API

Take website screenshots in Node.js 22 with TypeScript and ESM: Puppeteer, Playwright or one fetch call to an API, with measured install cost and tested code.

To take a website screenshot in Node.js, start a headless browser, open the page and call page.screenshot(). With Playwright, in an ES module on Node 22:

import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'example.png' });
await browser.close();

That is the whole idea. The rest of this guide is about the three choices behind it: Puppeteer or Playwright, how much browser you install and keep up to date, and when to hand the browser off to an API instead. Each option has a complete TypeScript listing that ran on the date below, and one table compares them. For a deep dive into a single library, read our Playwright screenshot guide or Puppeteer screenshot guide.

Tested on 2026-10-18 on macOS 27.0.1 (Apple M1 Pro) with Node.js 22.18.0, TypeScript 7.0.2, tsx 4.23.15, [email protected] (Chrome 155.0.8059.79) and [email protected] (Chromium 156.0.8078.4). Our test sandbox blocked outbound traffic from the browser, so the browser listings captured a copy of example.com and a long test page served from localhost. The API listing ran against a local stub of our API that returns the documented headers and error bodies. Network time is therefore not in any number below.

Set up a Node 22 project for ESM and TypeScript

mkdir shots && cd shots
npm init -y
npm pkg set type=module
npm i -D [email protected] tsx @types/node@22
mkdir out

"type": "module" lets you use import and top-level await in .js and .ts files. Without it, Node treats .js files as CommonJS and the first import line fails. tsx runs TypeScript directly, so there is no build step. Both Puppeteer and Playwright ship their own types. To type-check in CI, run npx tsc --noEmit --module nodenext --strict --types node *.ts. With TypeScript 7, the --types node flag (or "types": ["node"] in tsconfig.json) is what makes process and Buffer known in a file that does not import a library which pulls the Node types in.

What each option installs

Before the code, look at the cost of the first npm install. We timed each install once, into an empty browser cache, on the test machine above:

Step Wall time Added to disk
npm i [email protected] (downloads Chrome and Chrome Headless Shell) 51.1 s 563 MB of browsers
npm i [email protected] (no browser) 0.8 s small
npx playwright install chromium (Chromium and its headless shell) 32.7 s 569 MB of browsers
Built-in fetch to an API 0 s nothing

One run on one connection is not a benchmark, so treat the times as an order of magnitude. The disk sizes are stable: about 560 MB of browser per machine, per CI cache, per Docker layer, and again on every library upgrade that bumps the browser. On Linux you also need the shared libraries Chrome links against and real fonts; npx playwright install --with-deps chromium installs them on Debian and Ubuntu.

Option 1: Puppeteer

// puppeteer-shot.ts - Node 22, [email protected]
import puppeteer from 'puppeteer';

const target = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 });
  await page.goto(target, { waitUntil: 'networkidle2', timeout: 30_000 });
  await page.screenshot({ path: 'out/puppeteer.png', fullPage: true });
  console.log('saved out/puppeteer.png');
} finally {
  await browser.close();
}

Run it with npx tsx puppeteer-shot.ts https://example.com. Against our long test page it wrote a 2560 x 14576 PNG: the 1280 px viewport at deviceScaleFactor: 2, and the whole scroll height because of fullPage: true.

Points that matter in Puppeteer 25:

  • puppeteer.launch() starts full Chrome in its new headless mode. That is the closest match to desktop Chrome, and it is also heavier than the headless shell. Pass headless: 'shell' for the lighter binary.
  • networkidle2 resolves when no more than two connections are open for 500 ms. It suits pages with analytics beacons that never stop. Use networkidle0 only when the page goes fully quiet.
  • page.waitForTimeout() is gone (removed in v22). For a fixed pause, use await new Promise((r) => setTimeout(r, 500)), but prefer a real condition such as page.waitForSelector().
  • try/finally closes Chrome on every path. Without it, a failed goto leaves a Chrome process behind, and a long-running worker leaks one per failure.

Option 2: Playwright

// playwright-shot.ts - Node 22, [email protected]
import { chromium } from 'playwright';

const target = process.argv[2] ?? 'https://example.com';
const browser = await chromium.launch();
try {
  const context = await browser.newContext({
    viewport: { width: 1280, height: 800 },
    deviceScaleFactor: 2,
  });
  const page = await context.newPage();
  await page.goto(target, { waitUntil: 'load', timeout: 30_000 });
  await page.locator('body p').first().waitFor(); // an element your page renders
  await page.screenshot({ path: 'out/playwright.png', fullPage: true });
  console.log('saved out/playwright.png');
} finally {
  await browser.close();
}

Points that matter in Playwright 1.64:

  • chromium.launch() starts the headless shell, not full Chromium. Pass channel: 'chromium' when you need the full browser.
  • deviceScaleFactor belongs on the context, not on page.setViewportSize(). A context is also the unit of isolation: cookies, storage and permissions do not leak between contexts.
  • Wait for something you can see. Our first draft waited for h1, and it timed out: the copy of example.com we fetched on 2026-10-18 has no <h1>, only a <p>. A locator wait is only as good as the selector; pick the element your page renders last.
  • Playwright has no networkidle2. Its networkidle means no requests for 500 ms, and the Playwright docs advise against relying on it. Prefer a locator.

Many pages with one browser

Starting a browser per URL is the most common performance mistake. Launch once, then open a context per URL with a fixed concurrency:

// batch.ts - one browser, at most LIMIT pages at a time
import { chromium, type Browser } from 'playwright';

const LIMIT = 4;
const urls = process.argv.slice(2);

async function capture(browser: Browser, url: string, path: string) {
  const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
  try {
    const page = await context.newPage();
    await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
    await page.screenshot({ path });
  } finally {
    await context.close();
  }
}

const browser = await chromium.launch();
let failed = 0;
try {
  const queue = urls.map((url, i) => ({ url, path: `out/batch-${i}.png` }));
  const workers = Array.from({ length: LIMIT }, async () => {
    for (let job = queue.shift(); job; job = queue.shift()) {
      try {
        await capture(browser, job.url, job.path);
      } catch (err) {
        failed++;
        console.error(`FAILED ${job.url}: ${(err as Error).message.split('\n')[0]}`);
      }
    }
  });
  await Promise.all(workers);
} finally {
  await browser.close();
}
console.log(`${urls.length - failed}/${urls.length} captured`);
process.exitCode = failed ? 1 : 0;

We passed five URLs, one of them to a port Chrome refuses. The script captured four, printed FAILED http://127.0.0.1:9/: page.goto: net::ERR_UNSAFE_PORT, and exited with code 1. One bad URL does not stop the batch, and the exit code still tells cron or CI that something failed. The LIMIT is a memory budget: each open page holds a renderer process. For queue-based designs across machines, see scaling Puppeteer screenshots.

Option 3: one API call

The third option moves the browser off your machine. With RenderScreenshot it is one fetch, with no dependencies:

// api.ts - Node 22 built-in fetch, no dependencies
import { writeFile } from 'node:fs/promises';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';

const res = await fetch(`${API}/v1/screenshot`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: process.argv[2] ?? 'https://example.com',
    viewport: { width: 1280, height: 800, scale: 2 },
    capture: { mode: 'full_page' },
    block: { cookie_banners: true, ads: true },
  }),
});

if (!res.ok) {
  const { error } = await res.json();
  console.error(`${res.status} ${error.code}: ${error.message}`);
  process.exit(1);
}
await writeFile('out/api.png', Buffer.from(await res.arrayBuffer()));
console.log('title:', res.headers.get('x-screenshot-title'), '| CDN:', res.headers.get('x-cache-url'));

This is the same capture as the Puppeteer listing, plus two things the DIY scripts do not do: it removes ads and dismisses cookie banners (blocking options). The body is nested: viewport.scale, capture.mode, block.cookie_banners. The response carries the image, a public CDN copy in X-Cache-URL, and the target page's title in X-Screenshot-Title. On a bad URL, our run printed 400 invalid_url: The URL 'not-a-url' is not a valid HTTP(S) URL and exited with code 1. If you prefer a typed client, the Node.js SDK wraps the same request as client.take(TakeOptions.url(...)).

Puppeteer vs Playwright vs API

The one table to decide with. The timing rows are our own three rounds of ten captures each, of the local example.com copy at 1280 x 800 with one reused browser. The steady-state value is the median of captures 2 to 10.

Puppeteer 25.14 Playwright 1.64 Screenshot API
Browser on your machine Chrome + headless shell, 563 MB Chromium + headless shell, 569 MB None
Default binary Full Chrome, new headless Chromium headless shell Managed Chromium
launch() in our runs 417 to 908 ms 75 to 200 ms No launch
Steady capture, median 103 to 116 ms 75 to 91 ms Not measured here (network-bound)
PNG of example.com at 1280 x 800 18,566 bytes 18,497 bytes Not measured here
Browsers Chrome, Firefox Chromium, Firefox, WebKit Chromium
Cookie banners and ads Your code Your code block.* options
Fonts, emoji, Linux libraries You install them You install them Handled
Cost per capture Your servers Your servers Credits, see pricing

How to read it. On a local page, both libraries capture in about a tenth of a second once the browser is running, and the difference between them is mostly the binary they launch, as our Puppeteer vs Playwright measurements show in detail. The first capture after a launch varied a lot in our Puppeteer runs (151 ms to 2.3 s), so measure your own cold path if latency matters. The API row has no timings because we did not call the production API for this page; its latency is dominated by loading the target page over the network, which a localhost test cannot show.

Pick Puppeteer when you already know it or need Chrome DevTools Protocol access. Pick Playwright for new code: contexts, locators and three browser engines. Pick the API when you capture third-party pages, run on serverless or small containers, or do not want to own Chrome upgrades and fonts.

Troubleshooting

Symptom Cause Fix
SyntaxError: Cannot use import statement outside a module No "type": "module" npm pkg set type=module, or name files .mjs / .mts
TypeError: page.waitForTimeout is not a function Removed in Puppeteer v22 Wait for a selector or use setTimeout in a promise
TimeoutError on locator(...).waitFor() The selector is not on the page Check the live DOM; our h1 wait failed because the page has none
net::ERR_UNSAFE_PORT Chrome blocks some ports (for example 9, 25) Serve the page on another port
Navigation timeout of 30000 ms exceeded Slow page, or networkidle never reached Wait for load plus a selector instead
Image is blank or half-rendered Captured before content loaded Wait for the element that renders last; see SPA waits
Boxes instead of emoji or CJK text on Linux Fonts not installed apt-get install -y fonts-noto-color-emoji fonts-noto-cjk; see consistent fonts
error while loading shared libraries in Docker Chrome's system libraries missing npx playwright install --with-deps chromium, or a Playwright base image
Chrome processes pile up in a worker browser.close() skipped on error Close in finally; see timeouts and memory leaks
Script exits 0 after a failure Error caught and only logged Set process.exitCode = 1 or rethrow

Next steps

Related in this topic