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 ofexample.comand a long test page served fromlocalhost. 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. Passheadless: 'shell'for the lighter binary.networkidle2resolves when no more than two connections are open for 500 ms. It suits pages with analytics beacons that never stop. Usenetworkidle0only when the page goes fully quiet.page.waitForTimeout()is gone (removed in v22). For a fixed pause, useawait new Promise((r) => setTimeout(r, 500)), but prefer a real condition such aspage.waitForSelector().try/finallycloses Chrome on every path. Without it, a failedgotoleaves 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. Passchannel: 'chromium'when you need the full browser.deviceScaleFactorbelongs on the context, not onpage.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 ofexample.comwe 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. Itsnetworkidlemeans 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
- Try the same capture with your own key: get a free API key, then run
api.tswithRS_API_KEYset. The free plan includes 150 credits every month. - Tune the wait conditions: wait parameters and viewport parameters.
- Python instead of Node: website screenshots in Python.
Related in this topic
- How to Take Website Screenshots with Code: 6 Ways Take a screenshot of a website programmatically: DevTools, the Chrome CLI, Puppeteer, Playwright, Selenium or an API. Timed runs and code for each.
- JavaScript Screenshot of a Web Page: 3 Methods Tested Take a screenshot of a web page with JavaScript: Screen Capture API, html2canvas or a server render. Measured fidelity on 17 CSS features, timings and fixes.
- Selenium Screenshots in Python, Java and C# (Selenium 4) Take a screenshot with Selenium 4 in Python, Java and C#: page, element, exact viewport and PDF. Same test in three languages, timed, with the fixes.
- Playwright Screenshots: Tested Recipes and Pitfalls Take screenshots with Playwright: page.screenshot options, deviceScaleFactor, locators, masks, measured format sizes, auth, Linux fonts and production setup.
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Website Screenshots in Python: Selenium, Playwright, API Capture a website as an image in Python with Selenium, Playwright or an API: measured setup and per-shot time, async batches, and fixes for common failures.