Puppeteer Screenshot Tutorial: From Basics to Production
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 puppeteerWhen 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-coreThen 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 theloadevent (default)domcontentloaded— Wait forDOMContentLoadedeventnetworkidle0— Wait until no network connections for 500msnetworkidle2— 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 minutesChrome 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.