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
| Event | Puppeteer | Playwright | Use when |
|---|---|---|---|
| DOM ready | domcontentloaded | domcontentloaded | Static pages, fastest |
| All resources loaded | load | load | Pages with images/fonts |
| No network activity | networkidle2 | networkidle | SPAs, AJAX-heavy pages |
| First render | — | commit | Fastest 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);
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
| Method | Install size | Cold start | Memory/capture | Anti-bot | Works on Lambda |
|---|---|---|---|---|---|
| Puppeteer | ~170MB | 1-3s | ~150MB | ❌ blocked | ⚠️ tricky |
| Playwright | ~250MB | 1-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.
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.