Back to blog
Node.jsPuppeteerScreenshot API

Convert a Webpage to an Image in Node.js: 3 Working Methods

A hands-on webpage to image Node.js tutorial covering Puppeteer, Playwright, and API-based capture with real code, trade-offs, and production tips.

2026-06-085 min read

Turning a URL into a PNG sounds trivial until you actually ship it. Fonts don't load, lazy images stay blank, headless Chrome eats 800MB of RAM, and your Lambda times out on the third request. This webpage to image Node.js tutorial walks through three approaches that actually work in production, with code you can paste into a project today.

What you'll build

A small Node.js module with a single function: captureUrl(url, options) that returns a PNG buffer. We'll implement it three ways:

  1. Puppeteer (self-hosted Chromium)
  2. Playwright (multi-browser support)
  3. A screenshot API call (no browser dependency)

Each has a place. Pick based on volume, latency tolerance, and how much infrastructure you want to babysit.

Method 1: Puppeteer

Puppeteer is the default choice. It's maintained by the Chrome team, well-documented, and handles most edge cases out of the box.

Install

npm install puppeteer

This pulls down a matching Chromium build (~170MB). If you're deploying to a slim container, use puppeteer-core and point it at a system Chromium instead.

Basic capture

import puppeteer from 'puppeteer';

export async function captureUrl(url, { width = 1280, height = 800, fullPage = false } = {}) {
  const browser = await puppeteer.launch({
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  });
  try {
    const page = await browser.newPage();
    await page.setViewport({ width, height, deviceScaleFactor: 2 });
    await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
    return await page.screenshot({ type: 'png', fullPage });
  } finally {
    await browser.close();
  }
}

Things that will bite you

  • Web fonts aren't ready when DOMContentLoaded fires. Add await page.evaluateHandle('document.fonts.ready') before screenshotting.
  • Lazy-loaded images stay blank on fullPage shots. Scroll the page manually, or set page.emulateMediaFeatures and trigger intersection observers.
  • Cold start cost. Launching Chromium takes 1.5–3 seconds. Reuse the browser instance across requests with a pool.
  • Memory. Each tab leaks if you don't close it. Wrap everything in try/finally.

Reusing the browser

let browserPromise;
function getBrowser() {
  if (!browserPromise) {
    browserPromise = puppeteer.launch({ args: ['--no-sandbox'] });
  }
  return browserPromise;
}

This cuts per-request latency by roughly 80%. Just make sure you crash cleanly when the browser dies — add a disconnected listener that nulls out browserPromise.

Method 2: Playwright

Playwright is the same idea with a nicer API and built-in support for Firefox and WebKit. Useful if you need to screenshot how a page renders in Safari, or if you want auto-waiting selectors.

npm install playwright
import { chromium } from 'playwright';

export async function captureUrl(url, opts = {}) {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: opts.width || 1280, height: opts.height || 800 },
    deviceScaleFactor: 2
  });
  const page = await context.newPage();
  await page.goto(url, { waitUntil: 'networkidle' });
  const buffer = await page.screenshot({ fullPage: opts.fullPage });
  await browser.close();
  return buffer;
}

Differences from Puppeteer worth knowing:

  • networkidle in Playwright waits for 500ms of no network activity. Puppeteer's networkidle2 waits until ≤2 requests are in flight.
  • Playwright's locator API auto-waits for elements, which is handy if you're screenshotting a specific component.
  • Browser install is heavier (all three engines by default). Use npx playwright install chromium to grab just one.

Method 3: A screenshot API

Self-hosting headless browsers is fine until you need to scale. Then you're managing container images, Chromium crashes, font installations, IP rotation for sites that block datacenter ranges, and a queue system so one slow page doesn't take down your service.

At that point, calling an API is usually cheaper. PxShot is built for this — you send a GET request with a URL and it returns the rendered image. No browser dependency, no cold starts, no memory tuning.

Same function, API version

export async function captureUrl(url, { width = 1280, height = 800, format = 'png', fullPage = false } = {}) {
  const params = new URLSearchParams({
    url,
    width: String(width),
    height: String(height),
    format,
    full_page: String(fullPage)
  });
  const res = await fetch(`https://api.pxshot.dev/v1/screenshot?${params}`, {
    headers: { 'Authorization': `Bearer ${process.env.PXSHOT_KEY}` }
  });
  if (!res.ok) throw new Error(`PxShot ${res.status}: ${await res.text()}`);
  return Buffer.from(await res.arrayBuffer());
}

Drop-in replacement for the Puppeteer version. No dependencies beyond fetch, which is built into Node 18+.

Choosing between them

Rough decision tree:

  • Under 100 captures/day, internal tool: Puppeteer. Free, full control.
  • Cross-browser testing or component screenshots: Playwright.
  • Public-facing feature (OG images, link previews, PDF exports): An API. The math almost always favours it once you account for engineering time on retries, queueing, and Chromium upgrades.
  • Serverless (Lambda, Vercel, Cloudflare): An API. Bundling Chromium into a function is painful and slow.

Common gotchas regardless of method

Dynamic content

Single-page apps often finish loading before the visible content renders. Wait for a specific selector instead of relying on network idle:

await page.waitForSelector('[data-loaded="true"]', { timeout: 10000 });

Cookie banners and overlays

Inject CSS to hide them before screenshotting:

await page.addStyleTag({ content: '#cookie-banner, .gdpr-overlay { display: none !important; }' });

Authenticated pages

Set cookies before goto:

await page.setCookie({ name: 'session', value: token, domain: 'example.com' });

For API-based capture, PxShot accepts a cookies parameter so you can screenshot logged-in views without exposing your session logic.

Image format choice

  • PNG: Lossless, larger files. Use for screenshots with text or sharp edges.
  • JPEG: Smaller, lossy. Fine for full-page captures going into a CDN.
  • WebP: Best compression. Supported by all modern browsers.
  • PDF: Use when you need vector text and selectable content.

Caching

If you're generating OG images for blog posts, cache by content hash, not URL. Otherwise a typo fix in a title forces a re-render across every social platform. Store the image in S3 or R2 and serve from a CDN — never regenerate on every request.

If you'd rather skip the Chromium babysitting entirely, spin up a free PxShot key — the free tier covers a few hundred captures a month, which is plenty to wire up OG images or PDF exports before deciding what scales best for you.