Back to Blog
puppeteer screenshotpuppeteer tutorialfull page screenshotheadless chrome

Puppeteer Screenshot Tutorial: From Basics to Production

ST
ScreenURL Team
10 min read

Puppeteer Screenshot Tutorial: From Basics to Production

Capturing website screenshots programmatically is one of those tasks that seems simple until you actually try to do it. Whether you're generating Open Graph images for social sharing, building a visual monitoring system, creating PDFs from web pages, or archiving content—you need a reliable way to render websites and capture the result.

Puppeteer is the go-to solution. It's Google's official Node.js library for controlling headless Chrome, and it's incredibly powerful. In this tutorial, we'll walk through everything from basic screenshots to advanced techniques like full-page captures and waiting for dynamic content.

Then we'll talk about what the tutorials don't tell you: the challenges of running Puppeteer in production, and when a managed API might save you serious headaches.

Getting Started with Puppeteer

First, let's set up Puppeteer and capture our first screenshot.

Installation

npm install puppeteer

When you install Puppeteer, it automatically downloads a compatible version of Chromium (~170MB). This ensures consistency, but it also means your deployment size just grew significantly.

If you want to use your system's Chrome installation instead (useful for Docker or CI environments), you can install the lighter version:

npm install puppeteer-core

Then point it to your existing Chrome binary:

const browser = await puppeteer.launch({
  executablePath: '/usr/bin/google-chrome'
});

Your First Screenshot

Here's the minimal code to capture a screenshot:

const puppeteer = require('puppeteer');

async function takeScreenshot(url, outputPath) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  
  await page.goto(url);
  await page.screenshot({ path: outputPath });
  
  await browser.close();
}

takeScreenshot('https://github.com', 'github.png');

Run it with node screenshot.js, and you'll get a PNG screenshot of GitHub's homepage. Simple enough.

But this basic example hides a lot of complexity. Let's dig into the details.

Controlling Viewport Size

By default, Puppeteer uses an 800x600 viewport—pretty small for modern websites. Most sites are designed for larger screens, and you'll want to match realistic device dimensions.

const puppeteer = require('puppeteer');

async function takeScreenshot(url, outputPath) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  
  // Set viewport to standard desktop size
  await page.setViewport({
    width: 1920,
    height: 1080,
    deviceScaleFactor: 1
  });
  
  await page.goto(url);
  await page.screenshot({ path: outputPath });
  
  await browser.close();
}

For mobile screenshots, use device-specific dimensions:

// iPhone 14 Pro dimensions
await page.setViewport({
  width: 393,
  height: 852,
  deviceScaleFactor: 3,  // Retina display
  isMobile: true,
  hasTouch: true
});

Puppeteer also includes built-in device emulation:

const iPhone = puppeteer.KnownDevices['iPhone 14 Pro'];
await page.emulate(iPhone);

Full Page Screenshots

The default screenshot only captures the visible viewport. For full-page captures—essential for documentation, archiving, or visual regression testing—add the fullPage option:

await page.screenshot({ 
  path: 'fullpage.png',
  fullPage: true 
});

This tells Puppeteer to scroll through the entire page and stitch together a complete screenshot. Works great for most sites, though extremely long pages can produce massive images.

Waiting for Dynamic Content

Here's where things get tricky. Modern websites are JavaScript-heavy. React, Vue, Next.js—they all render content after the initial page load. If you take a screenshot immediately, you'll capture a loading spinner or blank content.

Wait for Navigation

The most basic approach is waiting for the page to fully load:

await page.goto(url, {
  waitUntil: 'networkidle2'  // Wait until network is idle
});

The waitUntil options are:

  • load — Wait for the load event (default)
  • domcontentloaded — Wait for DOMContentLoaded event
  • networkidle0 — Wait until no network connections for 500ms
  • networkidle2 — Wait until ≤2 network connections for 500ms

For most JavaScript-heavy sites, networkidle2 works well. It waits for the main content to load while allowing background requests like analytics.

Wait for Specific Elements

Sometimes you need to wait for a specific element to appear:

await page.goto(url);

// Wait for the main content container to render
await page.waitForSelector('.main-content', {
  visible: true,
  timeout: 10000
});

await page.screenshot({ path: 'screenshot.png' });

This is useful when you know exactly what element indicates the page is ready.

Adding a Delay

For sites with complex animations or lazy-loaded content, sometimes you just need to wait:

await page.goto(url, { waitUntil: 'networkidle2' });

// Wait an additional 2 seconds for animations to complete
await new Promise(resolve => setTimeout(resolve, 2000));

await page.screenshot({ path: 'screenshot.png' });

It's not elegant, but it's effective for edge cases.

Handling Different Output Formats

Puppeteer supports PNG, JPEG, and WebP formats:

// High-quality JPEG (smaller file size)
await page.screenshot({ 
  path: 'screenshot.jpg',
  type: 'jpeg',
  quality: 90
});

// WebP for best compression
await page.screenshot({ 
  path: 'screenshot.webp',
  type: 'webp',
  quality: 85
});

// PNG with transparency (default)
await page.screenshot({ 
  path: 'screenshot.png',
  type: 'png'
});

For web use, JPEG or WebP at 80-90 quality typically provides the best balance of quality and file size.

Complete Production-Ready Example

Here's a more robust implementation that handles common edge cases:

const puppeteer = require('puppeteer');

async function captureScreenshot(url, options = {}) {
  const {
    width = 1920,
    height = 1080,
    fullPage = false,
    delay = 0,
    format = 'png',
    quality = 90
  } = options;

  let browser;
  
  try {
    browser = await puppeteer.launch({
      headless: 'new',
      args: [
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '--disable-dev-shm-usage',  // Fixes memory issues in Docker
        '--disable-gpu'
      ]
    });

    const page = await browser.newPage();
    
    await page.setViewport({ width, height });
    
    // Block unnecessary resources for faster loading
    await page.setRequestInterception(true);
    page.on('request', (req) => {
      const resourceType = req.resourceType();
      if (['media', 'font'].includes(resourceType)) {
        req.abort();
      } else {
        req.continue();
      }
    });

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

    if (delay > 0) {
      await new Promise(resolve => setTimeout(resolve, delay));
    }

    const screenshotOptions = {
      fullPage,
      type: format
    };
    
    if (format === 'jpeg' || format === 'webp') {
      screenshotOptions.quality = quality;
    }

    const screenshot = await page.screenshot(screenshotOptions);
    
    return screenshot;
    
  } catch (error) {
    console.error('Screenshot failed:', error.message);
    throw error;
  } finally {
    if (browser) {
      await browser.close();
    }
  }
}

// Usage
captureScreenshot('https://news.ycombinator.com', {
  fullPage: true,
  delay: 1000,
  format: 'jpeg',
  quality: 85
}).then(buffer => {
  require('fs').writeFileSync('screenshot.jpg', buffer);
  console.log('Screenshot saved!');
});

This handles viewport sizing, format options, delays, resource blocking, and proper cleanup. It's a solid foundation.

The Production Challenges

Here's what the tutorials don't tell you: running Puppeteer in production is hard.

Server Requirements

Chrome is a memory hog. Each browser instance needs 200-500MB of RAM, and that's before you handle concurrent requests. A basic EC2 instance (t3.small, 2GB RAM) can handle maybe 2-3 concurrent screenshots before things start failing.

For any real traffic, you need:

  • At least 4GB RAM for comfortable headroom
  • CPU-optimized instances (Chrome is CPU-intensive)
  • Proper browser pooling to reuse instances
  • Queue systems to handle traffic spikes

Memory Leaks and Zombie Processes

Chrome doesn't always clean up after itself. Browser processes hang around, memory accumulates, and eventually your server crashes. You'll write cleanup scripts, implement browser recycling, and still wake up to crashed servers.

// The cleanup code you'll inevitably write
setInterval(() => {
  exec('pkill -f chromium', (err) => {
    if (!err) console.log('Cleaned up zombie Chrome processes');
  });
}, 300000); // Every 5 minutes

Chrome Updates Break Things

Google updates Chrome frequently. Each update can change rendering behavior, break your careful timing logic, or introduce new memory issues. You'll spend time tracking down why screenshots suddenly look different or fail entirely.

Scaling Headaches

Traffic spikes? Hope your infrastructure handles it. You'll need:

  • Load balancing across multiple servers
  • Browser pools with proper lifecycle management
  • Queue systems for request handling
  • Monitoring for process health

Each piece adds complexity and failure points.

The API Alternative

For production use cases, a managed screenshot API eliminates all of this complexity.

With ScreenURL, the same functionality becomes a single HTTP request:

// Puppeteer version: 50+ lines of code, server infrastructure required
// ScreenURL version:

const url = 'https://news.ycombinator.com';
const apiKey = 'your-api-key';

const screenshotUrl = `https://screenurl.com/api/screenshot?url=${encodeURIComponent(url)}&fullPage=true&width=1920&height=1080&apiKey=${apiKey}`;

// Use directly in an img tag
<img src={screenshotUrl} alt="Screenshot" />

// Or fetch the image
const response = await fetch(screenshotUrl);
const imageBlob = await response.blob();

Same capabilities—custom viewports, full-page captures, delays, format options—without managing any infrastructure.

Side-by-Side Comparison

Full-page screenshot with custom viewport:

// Puppeteer
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.goto(url, { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'output.png', fullPage: true });
await browser.close();

// ScreenURL
const screenshot = await fetch(
  `https://screenurl.com/api/screenshot?url=${url}&width=1440&height=900&fullPage=true&apiKey=${key}`
);

Mobile screenshot with delay:

// Puppeteer
await page.setViewport({ width: 390, height: 844, isMobile: true });
await page.goto(url, { waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 2000));
await page.screenshot({ path: 'mobile.png' });

// ScreenURL
const screenshot = await fetch(
  `https://screenurl.com/api/screenshot?url=${url}&width=390&height=844&delay=2000&apiKey=${key}`
);

The functionality is identical. The operational burden is not.

When to Use Which

This isn't about one being "better" than the other. They're tools for different situations.

Use Puppeteer When:

  • Local development and testing — Quick prototypes, one-off scripts
  • Screenshots of localhost — APIs can't reach your local server
  • Internal/private URLs — Behind firewalls or VPNs
  • Complex browser automation — Form filling, clicking, multi-step workflows
  • You need full programmatic control — Custom JavaScript execution, DOM manipulation
  • Screenshots are your core product — You're building a screenshot service

Use a Screenshot API When:

  • Production applications — Reliability and uptime matter
  • Public URLs — Most common use case
  • You need to ship fast — Integration in minutes, not days
  • Limited DevOps resources — No infrastructure to manage
  • Scaling is uncertain — Pay for what you use, scale automatically
  • Screenshots are a feature, not the product — Focus on your core business

The Cost Reality

Let's be honest about costs. Puppeteer is "free" but running it isn't:

Self-hosted (3,000 screenshots/month):

  • EC2 instance: $60-80/month
  • DevOps time (5 hours @ $75): $375/month
  • Total: ~$435-455/month

ScreenURL Pro (5,000 screenshots/month):

  • Total: $29/month

The break-even point is somewhere around 100,000+ screenshots per month with dedicated DevOps staff. For most teams, the API is dramatically cheaper when you account for developer time.

Quick Reference: Common Puppeteer Options

Before we wrap up, here's a quick reference for the most-used Puppeteer screenshot options:

Option Type Description
path string File path to save the screenshot
type 'png' | 'jpeg' | 'webp' Image format
quality number (0-100) JPEG/WebP quality
fullPage boolean Capture full scrollable page
clip object Capture specific region {x, y, width, height}
omitBackground boolean Transparent background for PNGs
encoding 'base64' | 'binary' Return type when no path specified

Conclusion

Puppeteer is a powerful tool, and this tutorial gives you everything you need to capture screenshots effectively. The code examples here work—use them for local development, testing, and one-off tasks.

But when you're ready to move to production, consider whether managing Chrome infrastructure is the best use of your time. The initial setup might seem manageable, but the ongoing maintenance—handling Chrome updates, debugging memory leaks, scaling for traffic spikes—adds up quickly.

For most applications, a managed API provides the same results with none of the operational headaches. You get the reliability of professionally maintained infrastructure without the DevOps burden.

Try ScreenURL free → — 100 screenshots/month, no credit card required. See if it fits your workflow before committing to building infrastructure.

Or bookmark this tutorial and build it yourself. Either way, you now have the knowledge to capture screenshots like a pro.