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.
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:
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:
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.
A managed screenshot API handles all of the above behind a single endpoint. Here's the same task with GrabShot:
curl "https://grabshot.dev/api/screenshot?url=https://example.com&width=1280&fullPage=true" \
-H "X-Api-Key: YOUR_API_KEY" \
--output screenshot.png
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());
}
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.
| Factor | Self-hosted Puppeteer | Screenshot API |
|---|---|---|
| Setup time | Hours to days | Minutes |
| Infrastructure | You manage servers, Docker, Chrome | None |
| Scaling | Manual (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 |
| Latency | Lower (no network hop) | Slightly higher (~200ms overhead) |
| Cookie banners | DIY detection | Often handled automatically |
| Maintenance | Chrome updates, security patches, monitoring | Zero |
| Customization | Unlimited | What the API offers |
| Language support | Node.js (or Playwright for others) | Any language with HTTP |
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.
Self-hosting makes sense when:
An API is the better choice when:
25 screenshots/month on the free plan. No credit card required. See if an API fits your workflow before committing.
Try It Now →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.
Ask yourself three questions:
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.