You can take a screenshot of any URL in Node.js using Puppeteer, Playwright, or a screenshot API. Puppeteer and Playwright launch a headless browser locally, giving you full control but requiring Chrome/Chromium as a dependency. Screenshot APIs handle the browser remotely and return an image over HTTP, cutting setup to a single npm install.
This guide covers all three methods with working code, then compares them so you can pick the right tool for your project.
Method 1: Puppeteer
Puppeteer is Google's official Node.js library for controlling Chrome via the DevTools Protocol. It ships its own Chromium binary (~300 MB download), so you get a consistent browser without managing system dependencies manually.
Install
npm install puppeteer
This downloads Chromium automatically. If you already have Chrome installed and want to skip the download:
npm install puppeteer-core
With puppeteer-core, you need to point executablePath to your Chrome binary.
Basic Screenshot
const puppeteer = require('puppeteer');
async function takeScreenshot(url, outputPath) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
await page.screenshot({ path: outputPath });
await browser.close();
}
takeScreenshot('https://example.com', 'screenshot.png');
networkidle2 waits until there are no more than 2 network connections for 500ms. That handles most pages, but SPAs with long-polling connections sometimes need a different strategy.
Full-Page Capture
By default, Puppeteer captures only the viewport. To grab the entire scrollable page:
await page.screenshot({
path: 'full-page.png',
fullPage: true
});
One thing to watch: pages with infinite scroll or lazy-loaded content below the fold won't fully render. The screenshot captures what the DOM contains at that moment, not what a user would see after scrolling.
Custom Viewport and Mobile Emulation
const browser = await puppeteer.launch();
const page = await browser.newPage();
// Desktop: 1920x1080
await page.setViewport({ width: 1920, height: 1080 });
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'desktop.png' });
// Mobile: iPhone 14 dimensions with device scale factor
await page.setViewport({
width: 390,
height: 844,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true
});
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'mobile.png' });
await browser.close();
Puppeteer also ships built-in device descriptors:
const iPhone = puppeteer.KnownDevices['iPhone 14'];
await page.emulate(iPhone);
Waiting Strategies
Choosing the right wait condition is the single biggest source of flaky screenshots. Here are your options:
| Strategy | What It Does | Best For |
|---|---|---|
load |
Waits for the load event |
Simple static pages |
domcontentloaded |
DOM parsed, subresources still loading | Fast captures where you don't need images |
networkidle0 |
No network connections for 500ms | Pages that finish loading cleanly |
networkidle2 |
2 or fewer connections for 500ms | Pages with analytics, tracking pixels |
For SPAs that render client-side, you often need to wait for a specific element:
await page.goto('https://spa-app.com');
await page.waitForSelector('#main-content', { timeout: 10000 });
await page.screenshot({ path: 'spa.png' });
Or wait for a fixed delay as a last resort:
await page.goto('https://heavy-animations.com');
await new Promise(resolve => setTimeout(resolve, 3000));
await page.screenshot({ path: 'animated.png' });
PDF Output
Puppeteer can generate PDFs, but only in headless mode:
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.pdf({
path: 'page.pdf',
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
});
await browser.close();
printBackground is important. Without it, you lose background colors and images.
Handling Common Issues
Cookie consent banners block the content you want to capture. You can dismiss them:
await page.goto(url, { waitUntil: 'networkidle2' });
try {
await page.click('[class*="cookie"] button', { timeout: 3000 });
await new Promise(resolve => setTimeout(resolve, 500));
} catch {
// No cookie banner found, continue
}
await page.screenshot({ path: 'clean.png' });
Hiding elements (ads, popups, sticky headers):
await page.evaluate(() => {
document.querySelectorAll('[class*="popup"], [class*="modal"]')
.forEach(el => el.style.display = 'none');
});
This works but it's brittle. Selectors change, new popups appear, and you end up maintaining a growing list of CSS selectors.
Method 2: Playwright
Playwright is Microsoft's browser automation library. The API is similar to Puppeteer, but it supports Chromium, Firefox, and WebKit out of the box.
Install
npm install playwright
npx playwright install
The second command downloads all three browser binaries. To install only Chromium:
npx playwright install chromium
Basic Screenshot
const { chromium } = require('playwright');
async function takeScreenshot(url, outputPath) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.screenshot({ path: outputPath });
await browser.close();
}
takeScreenshot('https://example.com', 'screenshot.png');
The API is almost identical to Puppeteer. The main differences:
-
waitUntil: 'networkidle'instead ofnetworkidle0/networkidle2 - Auto-waiting on selectors (no need for explicit
waitForSelectorbefore clicking) -
page.locator()for more resilient element targeting
Full-Page and Viewport
const { chromium } = require('playwright');
async function captureFullPage(url) {
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1280, height: 720 }
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.screenshot({
path: 'full-page.png',
fullPage: true
});
await browser.close();
}
Cross-Browser Screenshots
This is where Playwright shines. You can capture the same URL in three engines:
const { chromium, firefox, webkit } = require('playwright');
async function crossBrowserScreenshots(url) {
for (const browserType of [chromium, firefox, webkit]) {
const browser = await browserType.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.screenshot({
path: `screenshot-${browserType.name()}.png`
});
await browser.close();
}
}
crossBrowserScreenshots('https://example.com');
If you're doing visual regression testing across browsers, Playwright is the obvious choice.
Device Emulation
const { chromium, devices } = require('playwright');
const iPhone = devices['iPhone 14'];
async function mobileScreenshot(url) {
const browser = await chromium.launch();
const context = await browser.newContext({ ...iPhone });
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
await page.screenshot({ path: 'mobile.png' });
await browser.close();
}
Playwright's device list is extensive and regularly updated.
Method 3: Screenshot APIs
Both Puppeteer and Playwright require you to run a headless browser. That means managing Chromium binaries, handling memory usage, dealing with zombie processes, and tuning timeouts for every edge case.
Screenshot APIs handle all of that remotely. You send an HTTP request with the URL and options, and you get an image back.
Using SnapRender's Node.js SDK
npm install snaprender
const { SnapRender } = require('snaprender');
const fs = require('fs');
const client = new SnapRender({ apiKey: 'sk_live_...' });
const buffer = await client.screenshot({
url: 'https://example.com',
format: 'png',
width: 1280,
height: 720,
full_page: true
});
fs.writeFileSync('screenshot.png', buffer);
Features like ad blocking, cookie banner removal, dark mode, and device emulation are just parameters. No CSS selector hunting, no page.evaluate() hacks.
Using Raw fetch
If you don't want an SDK dependency, the API is a single GET request:
const url = new URL('https://app.snap-render.com/v1/screenshot');
url.searchParams.set('url', 'https://example.com');
url.searchParams.set('format', 'png');
url.searchParams.set('width', '1280');
url.searchParams.set('full_page', 'true');
const response = await fetch(url, {
headers: { 'X-API-Key': 'sk_live_...' }
});
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync('screenshot.png', buffer);
No browser binary. No process management. The heavy lifting happens on the API's servers.
Comparison
| Factor | Puppeteer | Playwright | Screenshot API |
|---|---|---|---|
| Install size | ~300 MB (includes Chromium) | ~400 MB (three browsers) | ~50 KB (SDK only) |
| Setup time | 5-10 min | 5-10 min | 2 min |
| Lines of code for basic screenshot | 8-10 | 8-10 | 3-5 |
| Full-page capture | Built-in | Built-in | Built-in |
| Mobile emulation | Built-in | Built-in | Built-in |
| Ad/cookie banner removal | Manual CSS selectors | Manual CSS selectors | Single parameter |
| Dark mode forcing | Inject CSS / prefers-color-scheme
|
Inject CSS / prefers-color-scheme
|
Single parameter |
| Browser engines | Chromium only* | Chromium, Firefox, WebKit | Managed remotely |
| Concurrency | Limited by system RAM | Limited by system RAM | Handled by API |
| Server dependency | Runs locally | Runs locally | Requires network |
| Cost | Free (compute costs) | Free (compute costs) | Free tier + paid plans |
| Best for | Browser automation beyond screenshots | Cross-browser testing | Production screenshot pipelines |
*Puppeteer experimentally supports Firefox, but it's not production-ready.
Memory and Performance Considerations
Each Puppeteer/Playwright browser instance uses 100-300 MB of RAM. If you're capturing screenshots concurrently, that adds up fast. On a typical 2 GB server, you can run maybe 4-6 browser instances before things start swapping.
Common production issues with self-hosted headless browsers:
-
Zombie processes: If your script crashes without calling
browser.close(), Chrome processes pile up. You need process monitoring. - Memory leaks: Long-running browser instances leak memory over time. Most production setups restart the browser every N screenshots.
- Font rendering: Different OS environments render fonts differently. Your local screenshots won't match your CI screenshots.
-
GPU/sandbox issues: Docker containers need
--no-sandboxand--disable-gpuflags, which have security implications.
Screenshot APIs eliminate these operational concerns. You're trading control for reliability.
Which Method Should You Use?
Use Puppeteer if you need browser automation beyond screenshots. Form filling, navigation testing, scraping, and E2E testing all benefit from Puppeteer's full browser control. If screenshots are just one small part of a bigger automation workflow, Puppeteer makes sense.
Use Playwright if you need cross-browser testing. Capturing the same page in Chromium, Firefox, and WebKit with a single API is Playwright's strongest selling point. It also has better auto-waiting behavior and a more robust selector engine than Puppeteer.
Use a screenshot API if screenshots are the product feature, not a side effect of testing. Building an OG image service, a link preview system, a visual monitoring tool, or any feature where the output is the screenshot itself. The setup is minimal, scaling is someone else's problem, and edge cases like cookie banners and ad blocking are already solved.
For most production use cases where you need to take a screenshot of a URL in Node.js, the code-to-value ratio of an API is hard to beat. But if you're already running Puppeteer or Playwright for testing, adding screenshot capture to that existing setup costs you nothing.
Top comments (0)