Node.js Tutorial

How to Take a Screenshot of a Website in Node.js (2026)

April 202611 min readPuppeteer · Playwright · SnapAPI

Three ways to capture website screenshots in Node.js: Puppeteer (quick and battle-tested), Playwright (modern and cross-browser), and a screenshot API (no browser to manage). Code examples for every scenario.

Method 1: Puppeteer

Puppeteer is the original Node.js headless browser library from Google. It drives Chrome/Chromium via the DevTools Protocol and is the most widely used option.

Install

npm install puppeteer
# Downloads Chromium automatically (~170MB)

Basic Screenshot

const puppeteer = require('puppeteer');

async function screenshot(url, outputPath) {
  const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  });
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });

  await page.goto(url, { waitUntil: 'networkidle2' });
  await page.screenshot({ path: outputPath, fullPage: true });

  await browser.close();
}

screenshot('https://example.com', 'output.png');

Advanced Options

const puppeteer = require('puppeteer');

async function advancedScreenshot() {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();

  // --- Viewport and device emulation ---
  await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 2 }); // Retina

  // --- Block analytics/ads for cleaner capture ---
  await page.setRequestInterception(true);
  page.on('request', req => {
    const blocked = ['google-analytics', 'googletagmanager', 'doubleclick', 'adnxs'];
    if (blocked.some(s => req.url().includes(s))) req.abort();
    else req.continue();
  });

  await page.goto('https://example.com', { waitUntil: 'networkidle2', timeout: 30000 });

  // --- Wait for a specific element to appear ---
  await page.waitForSelector('.main-content', { timeout: 5000 });

  // --- Capture as PNG ---
  const png = await page.screenshot({ fullPage: true, type: 'png' });
  require('fs').writeFileSync('full.png', png);

  // --- Capture as JPEG (smaller file) ---
  const jpeg = await page.screenshot({ fullPage: true, type: 'jpeg', quality: 85 });
  require('fs').writeFileSync('full.jpg', jpeg);

  // --- Capture a specific element ---
  const element = await page.$('.hero-section');
  if (element) {
    await element.screenshot({ path: 'hero.png' });
  }

  // --- Clip to a specific region ---
  const clipped = await page.screenshot({
    clip: { x: 0, y: 0, width: 600, height: 400 }
  });
  require('fs').writeFileSync('clipped.png', clipped);

  await browser.close();
}

advancedScreenshot();

Puppeteer Mobile Screenshots

const puppeteer = require('puppeteer');
const { KnownDevices } = require('puppeteer');

async function mobileScreenshot(url) {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();

  // Use a predefined device profile
  const iPhone = KnownDevices['iPhone 14'];
  await page.emulate(iPhone);

  await page.goto(url, { waitUntil: 'networkidle2' });
  const screenshot = await page.screenshot({ fullPage: true });

  await browser.close();
  return screenshot;
}

Puppeteer PDF Export

await page.goto('https://example.com', { waitUntil: 'networkidle2' });
const pdf = await page.pdf({
  format: 'A4',
  printBackground: true,
  margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
});
require('fs').writeFileSync('output.pdf', pdf);

Method 2: Playwright

Playwright is Microsoft's modern alternative to Puppeteer. It supports Chromium, Firefox, and WebKit, has better async handling, and is the preferred choice for new projects in 2026.

Install

npm install playwright
npx playwright install chromium  # Install browser binary

Basic Screenshot

const { chromium } = require('playwright');

async function screenshot(url, outputPath) {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setViewportSize({ width: 1280, height: 800 });

  await page.goto(url, { waitUntil: 'networkidle' });
  await page.screenshot({ path: outputPath, fullPage: true });

  await browser.close();
}

screenshot('https://example.com', 'output.png');

Playwright vs Puppeteer: waitUntil Options

EventPuppeteerPlaywrightUse when
DOM readydomcontentloadeddomcontentloadedStatic pages, fastest
All resources loadedloadloadPages with images/fonts
No network activitynetworkidle2networkidleSPAs, AJAX-heavy pages
First rendercommitFastest possible capture

Screenshot with Auth (Cookies/Session)

const { chromium } = require('playwright');

async function authenticatedScreenshot(url, cookies) {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: 1280, height: 800 },
    // Pass cookies for authentication
    storageState: {
      cookies: cookies.map(c => ({
        name: c.name, value: c.value,
        domain: new URL(url).hostname,
        path: '/'
      }))
    }
  });

  const page = await context.newPage();
  await page.goto(url, { waitUntil: 'networkidle' });
  const screenshot = await page.screenshot({ fullPage: true });

  await browser.close();
  return screenshot;
}

Efficient: Reuse Browser Context

// Don't launch a new browser per screenshot — reuse a persistent browser
const { chromium } = require('playwright');

class ScreenshotService {
  constructor() { this.browser = null; }

  async init() {
    this.browser = await chromium.launch({ headless: true });
  }

  async capture(url, options = {}) {
    if (!this.browser) await this.init();
    const context = await this.browser.newContext({
      viewport: { width: options.width || 1280, height: options.height || 800 }
    });
    const page = await context.newPage();
    try {
      await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
      if (options.selector) {
        await page.waitForSelector(options.selector);
      }
      const screenshot = await page.screenshot({ fullPage: options.fullPage ?? true });
      return screenshot;
    } finally {
      await context.close();  // Close context, not browser
    }
  }

  async destroy() {
    if (this.browser) await this.browser.close();
  }
}

// Usage:
const service = new ScreenshotService();
const buf = await service.capture('https://example.com');
require('fs').writeFileSync('out.png', buf);
Memory warning: Never keep pages open after use. Always close the context/page in a finally block. Each leaked page = ~50MB RAM held until crash.

Method 3: Screenshot API (No Browser Management)

If you're shipping to production, managing a headless browser adds operational overhead: Docker image sizes (~500MB), memory limits, crash recovery, and anti-bot failures on Cloudflare-protected sites. A screenshot API handles all of it for you.

SnapAPI's Node.js SDK:

npm install snapapi-js
const { SnapAPI } = require('snapapi-js');

const client = new SnapAPI({ apiKey: process.env.SNAPAPI_KEY });

// Basic screenshot — returns Buffer
const png = await client.screenshot({
  url: 'https://example.com',
  fullPage: true,
  width: 1280,
  waitFor: 'networkidle',
  blockAds: true
});
require('fs').writeFileSync('output.png', png);
// Or use fetch directly — no SDK needed
const response = await fetch('https://api.snapapi.pics/v1/screenshot', {
  method: 'POST',
  headers: {
    'X-Api-Key': process.env.SNAPAPI_KEY,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    url: 'https://example.com',
    full_page: true,
    width: 1280,
    height: 800,
    wait_for: 'networkidle',
    block_ads: true,
    format: 'png'           // 'jpeg' or 'webp' also supported
  })
});

const { screenshot } = await response.json();
// screenshot = base64-encoded PNG
const buffer = Buffer.from(screenshot, 'base64');
require('fs').writeFileSync('output.png', buffer);

Device Emulation via API

// Mobile screenshot — no viewport math needed
const response = await fetch('https://api.snapapi.pics/v1/screenshot', {
  method: 'POST',
  headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    url: 'https://example.com',
    device: 'iPhone 14',    // 30+ device presets available
    full_page: true,
    stealth: true            // Bypass anti-bot detection
  })
});
const { screenshot } = await response.json();

Capture Custom HTML (Not a URL)

// Render raw HTML to PNG — great for OG images, reports
const html = `
  <html>
  <head><style>body { background: #1a1a2e; color: white; font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }</style></head>
  <body><h1>Hello from SnapAPI</h1></body>
  </html>
`;

const response = await fetch('https://api.snapapi.pics/v1/screenshot', {
  method: 'POST',
  headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ html, width: 1200, height: 630 })  // OG image dimensions
});
const { screenshot } = await response.json();

Method Comparison

MethodInstall sizeCold startMemory/captureAnti-botWorks on Lambda
Puppeteer~170MB1-3s~150MB❌ blocked⚠️ tricky
Playwright~250MB1-4s~180MB❌ blocked⚠️ tricky
SnapAPI~20KB (SDK)<50ms~1MB (HTTP)✅ stealth✅ native

Express API: Screenshot Endpoint

A common pattern: expose a screenshot endpoint from your Express app that proxies to your preferred method.

// routes/screenshot.js
const express = require('express');
const router = express.Router();

router.post('/screenshot', async (req, res) => {
  const { url, fullPage = true, width = 1280, device } = req.body;

  if (!url) return res.status(400).json({ error: 'url is required' });

  try {
    const response = await fetch('https://api.snapapi.pics/v1/screenshot', {
      method: 'POST',
      headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify({ url, full_page: fullPage, width, device, wait_for: 'networkidle' })
    });

    const data = await response.json();
    if (!response.ok) return res.status(502).json({ error: data.error || 'Capture failed' });

    // Return as image or base64 depending on Accept header
    if (req.headers.accept === 'application/json') {
      return res.json({ screenshot: data.screenshot });
    }

    const buffer = Buffer.from(data.screenshot, 'base64');
    res.set('Content-Type', 'image/png');
    res.set('Content-Length', buffer.length);
    res.send(buffer);

  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

module.exports = router;

Batch Screenshots

// Capture multiple URLs concurrently (respect rate limits)
async function batchScreenshots(urls, concurrency = 5) {
  const results = [];

  for (let i = 0; i < urls.length; i += concurrency) {
    const batch = urls.slice(i, i + concurrency);
    const batchResults = await Promise.allSettled(
      batch.map(url => fetch('https://api.snapapi.pics/v1/screenshot', {
        method: 'POST',
        headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify({ url, full_page: true, wait_for: 'networkidle' })
      }).then(r => r.json()))
    );
    results.push(...batchResults);
    console.log(`Captured ${Math.min(i + concurrency, urls.length)}/${urls.length}`);
  }

  return results;
}

const urls = [
  'https://example.com',
  'https://example.com/about',
  'https://example.com/pricing'
];
const results = await batchScreenshots(urls);

AWS Lambda / Serverless

Running Puppeteer or Playwright on Lambda requires @sparticuz/chromium and a custom layer — binary size is ~50MB compressed. With a screenshot API, Lambda is trivial:

// handler.js — Lambda function using SnapAPI
exports.handler = async (event) => {
  const { url } = event;

  const response = await fetch('https://api.snapapi.pics/v1/screenshot', {
    method: 'POST',
    headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url, full_page: true, wait_for: 'networkidle' })
  });

  const { screenshot } = await response.json();

  // Upload to S3, return URL, etc.
  return {
    statusCode: 200,
    body: JSON.stringify({ screenshot })
  };
};
// Lambda memory: 128MB. No layers. No binary. Cold start: <200ms.
Free to try: SnapAPI's free tier gives 200 screenshots/month — more than enough to test all the patterns in this post. Get your free API key →

Which Method Should You Use?

For local scripts and quick automation: Playwright (better API than Puppeteer, cross-browser support). For production services handling user-triggered screenshots: a screenshot API — operational simplicity beats marginal per-call cost every time. For high-volume batch jobs (>100K/month) where you can manage the infrastructure: self-hosted Playwright with a browser pool.

The practical rule: if you're debugging Chromium startup flags, you've already spent more than the API would have cost.