Puppeteer Screenshots vs Screenshot API: When to Build vs Buy

Published February 23, 2026 · 10 min read

Every developer building a screenshot feature faces the same question: should I spin up Puppeteer myself, or use a managed API? Both work. The right choice depends on your volume, your team, and how much you enjoy debugging Chrome memory leaks at 3 AM.

This guide walks through both approaches with real code, then gives you a framework to decide.

The DIY Route: Puppeteer Screenshots

Puppeteer is Google's Node.js library for controlling headless Chrome. It's powerful, free, and the go-to tool for browser automation. Here's a basic screenshot function:

Basic Puppeteer Screenshot (Node.js)

const puppeteer = require('puppeteer');

async function takeScreenshot(url, outputPath) {
  const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  });

  try {
    const page = await browser.newPage();
    await page.setViewport({ width: 1280, height: 800 });
    await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
    await page.screenshot({ path: outputPath, fullPage: true });
    return outputPath;
  } finally {
    await browser.close();
  }
}

Simple enough. But production is never simple. Here's what a real implementation needs:

Production-Grade Puppeteer (What You Actually Ship)

const puppeteer = require('puppeteer');
const genericPool = require('generic-pool');

// Browser pool to avoid launch overhead per request
const browserPool = genericPool.createPool({
  create: () => puppeteer.launch({
    headless: 'new',
    args: [
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-dev-shm-usage',  // Prevent /dev/shm overflow in Docker
      '--disable-gpu',
      '--single-process',
      '--no-zygote',
      '--max-old-space-size=512'
    ]
  }),
  destroy: (browser) => browser.close(),
  validate: (browser) => browser.isConnected()
}, { min: 2, max: 10, idleTimeoutMillis: 60000 });

async function takeScreenshot(url, options = {}) {
  const browser = await browserPool.acquire();

  try {
    const page = await browser.newPage();

    // Block unnecessary resources for speed
    await page.setRequestInterception(true);
    page.on('request', (req) => {
      if (['font', 'media'].includes(req.resourceType())) {
        req.abort();
      } else {
        req.continue();
      }
    });

    await page.setViewport({
      width: options.width || 1280,
      height: options.height || 800,
      deviceScaleFactor: options.retina ? 2 : 1
    });

    // Set a realistic user agent
    await page.setUserAgent(
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' +
      'AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36'
    );

    await page.goto(url, {
      waitUntil: options.waitUntil || 'networkidle2',
      timeout: options.timeout || 30000
    });

    // Optional: wait for a specific selector
    if (options.waitForSelector) {
      await page.waitForSelector(options.waitForSelector, { timeout: 10000 });
    }

    // Optional: dismiss cookie banners
    try {
      await page.evaluate(() => {
        document.querySelectorAll(
          '[class*="cookie"], [id*="cookie"], [class*="consent"]'
        ).forEach(el => el.remove());
      });
    } catch (e) { /* not every page has these */ }

    const screenshot = await page.screenshot({
      type: options.format || 'png',
      fullPage: options.fullPage || false,
      quality: options.format === 'jpeg' ? (options.quality || 80) : undefined
    });

    await page.close();
    return screenshot;

  } catch (err) {
    // Kill the browser on error (it might be corrupted)
    await browserPool.destroy(browser);
    throw err;
  } finally {
    try { await browserPool.release(browser); } catch (e) {}
  }
}

That's around 70 lines just for the capture function. You still need an HTTP server, a queue (for concurrent requests), storage, error handling, monitoring, and a Docker setup with Chrome installed.

The API Route: One HTTP Call

A managed screenshot API handles all of the above behind a single endpoint. Here's the same task with GrabShot:

curl

curl "https://grabshot.dev/api/screenshot?url=https://example.com&width=1280&fullPage=true" \
  -H "X-Api-Key: YOUR_API_KEY" \
  --output screenshot.png

Node.js

const fetch = require('node-fetch');
const fs = require('fs');

async function takeScreenshot(url) {
  const params = new URLSearchParams({
    url,
    width: '1280',
    fullPage: 'true',
    format: 'png'
  });

  const res = await fetch(
    `https://grabshot.dev/api/screenshot?${params}`,
    { headers: { 'X-Api-Key': process.env.GRABSHOT_KEY } }
  );

  if (!res.ok) throw new Error(`API error: ${res.status}`);
  return Buffer.from(await res.arrayBuffer());
}

Python

import requests

def take_screenshot(url: str) -> bytes:
    response = requests.get(
        "https://grabshot.dev/api/screenshot",
        params={
            "url": url,
            "width": 1280,
            "fullPage": "true",
            "format": "png"
        },
        headers={"X-Api-Key": "YOUR_API_KEY"}
    )
    response.raise_for_status()
    return response.content

That's it. No Chrome installation, no browser pool, no Docker config, no memory management.

Side-by-Side Comparison

FactorSelf-hosted PuppeteerScreenshot API
Setup timeHours to daysMinutes
InfrastructureYou manage servers, Docker, ChromeNone
ScalingManual (more servers, load balancing)Automatic
Cost at 1K/mo~$5-20/mo (small VPS)Free tier or ~$9/mo
Cost at 100K/mo~$100-400/mo (multiple servers)~$79/mo
LatencyLower (no network hop)Slightly higher (~200ms overhead)
Cookie bannersDIY detectionOften handled automatically
MaintenanceChrome updates, security patches, monitoringZero
CustomizationUnlimitedWhat the API offers
Language supportNode.js (or Playwright for others)Any language with HTTP

The Hidden Costs of Self-Hosting

The Puppeteer code above looks manageable. Here's what catches teams off guard:

None of these are unsolvable, but each one is a half-day of debugging when it first shows up in production.

When to Build (Puppeteer)

Self-hosting makes sense when:

When to Buy (API)

An API is the better choice when:

Try GrabShot Free

25 screenshots/month on the free plan. No credit card required. See if an API fits your workflow before committing.

Try It Now →

Hybrid Approach: The Best of Both

Some teams use both. Here's a pattern that works well:

async function screenshot(url, options = {}) {
  // Use local Puppeteer for internal URLs
  if (url.startsWith('https://internal.') || options.requiresAuth) {
    return puppeteerScreenshot(url, options);
  }

  // Use GrabShot API for everything else
  return grabshotScreenshot(url, options);
}

This gives you the reliability and zero-maintenance of an API for most captures, with the flexibility of Puppeteer when you need it.

Decision Framework

Ask yourself three questions:

  1. Is screenshot capture a core differentiator? If yes, build it. If it's just a feature, buy it.
  2. Do you have a DevOps team? If no, an API saves you from becoming one.
  3. Do you need to capture internal/authenticated pages? If yes, you'll need at least some Puppeteer. Consider the hybrid approach.

For most teams building SaaS products, dashboards, or content platforms, the API route gets you to production faster and keeps you there with less maintenance. The engineering time you save is worth more than the API cost.

Further Reading