How to Build a Puppeteer Screenshot API with Node.js
Build a Node.js HTTP endpoint that captures web pages with Puppeteer, returns image bytes, and handles common options and deployment concerns.
A Puppeteer screenshot API accepts an HTTP request, opens the requested page in Chromium, captures it with page.screenshot(), and returns the resulting image bytes. The core sequence is launch a browser, create a page, navigate, capture, and close. This guide builds a small Node.js service with a bounded set of options, input validation, timeouts, and runnable client examples.
The example uses Express for routing. Its route handlers map HTTP methods and paths to application code; the screenshot behavior itself comes from Puppeteer. [Express routing](https://expressjs.com/en/guide/routing/) · [Puppeteer screenshot guide](https://pptr.dev/guides/screenshots)
1. Create the project
Use a current Node.js installation and a package manager. The researched material does not establish a minimum Node.js version, so check the requirements of the Puppeteer and Express versions you install. This example uses ES modules.
mkdir screenshot-api
cd screenshot-api
npm init -y
npm install express puppeteer
Set "type": "module" in package.json. Puppeteer normally downloads a compatible browser during installation; its configuration guide documents browser download and executable path configuration. [Puppeteer configuration](https://pptr.dev/guides/configuration)
{
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.0.0",
"puppeteer": "^25.0.0"
}
}
Those version ranges are illustrative package declarations, not a compatibility guarantee. Use versions supported in your environment and commit the generated lockfile.
2. Build the screenshot endpoint
Create server.js. The route accepts a target url and an allowlisted set of query options. It deliberately does not pass arbitrary request parameters into Puppeteer. The service returns raw image bytes with the matching content type.
import express from 'express';
import puppeteer from 'puppeteer';
const app = express();
const port = Number(process.env.PORT || 3000);
const navigationTimeoutMs = 20_000;
let browserPromise;
function getBrowser() {
if (!browserPromise) {
browserPromise = puppeteer.launch({ headless: true });
}
return browserPromise;
}
function parseBoolean(value, fallback = false) {
if (value === undefined) return fallback;
if (value === 'true') return true;
if (value === 'false') return false;
throw new Error('Expected true or false');
}
function parseDimension(value, fallback, max) {
if (value === undefined) return fallback;
const n = Number(value);
if (!Number.isInteger(n) || n < 1 || n > max) {
throw new Error(`Dimension must be an integer from 1 to ${max}`);
}
return n;
}
function parseTarget(value) {
if (typeof value !== 'string' || value.length > 2048) {
throw new Error('url is required and must be at most 2048 characters');
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw new Error('url must be an absolute URL');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only http and https URLs are accepted');
}
return parsed.href;
}
app.get('/screenshot', async (req, res) => {
let page;
try {
const target = parseTarget(req.query.url);
const type = req.query.type ?? 'png';
if (!['png', 'jpeg', 'webp'].includes(type)) {
return res.status(400).json({ error: 'type must be png, jpeg, or webp' });
}
const width = parseDimension(req.query.width, 1280, 3840);
const height = parseDimension(req.query.height, 800, 3840);
const fullPage = parseBoolean(req.query.fullPage, false);
const omitBackground = parseBoolean(req.query.transparent, false);
let quality;
if (req.query.quality !== undefined) {
quality = Number(req.query.quality);
if (!Number.isInteger(quality) || quality < 0 || quality > 100) {
return res.status(400).json({ error: 'quality must be an integer from 0 to 100' });
}
if (type === 'png') {
return res.status(400).json({ error: 'quality applies only to jpeg and webp' });
}
}
const browser = await getBrowser();
page = await browser.newPage({ viewport: { width, height } });
page.setDefaultNavigationTimeout(navigationTimeoutMs);
await page.goto(target, { waitUntil: 'networkidle2' });
const screenshotOptions = {
type,
fullPage,
omitBackground,
...(quality === undefined ? {} : { quality }),
};
const bytes = await page.screenshot(screenshotOptions);
res.set('Content-Type', `image/${type}`);
res.set('Cache-Control', 'no-store');
res.send(Buffer.from(bytes));
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown capture error';
const status = /url|dimension|quality|Expected|Only http|must be/i.test(message) ? 400 : 502;
if (!res.headersSent) res.status(status).json({ error: message });
} finally {
if (page) await page.close().catch(() => {});
}
});
app.get('/health', (_req, res) => res.json({ ok: true }));
const server = app.listen(port, () => {
console.log(`Screenshot API listening on ${port}`);
});
async function shutdown() {
server.close();
if (browserPromise) {
try {
const browser = await browserPromise;
await browser.close();
} catch {}
}
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
Start it with npm start. The response body is the encoded image, not JSON or a base64 wrapper. Puppeteer returns a Uint8Array by default; converting it to a Node.js Buffer lets Express send the bytes directly. Its API also supports a base64 string when requested, but binary responses avoid that extra encoding. [Puppeteer Page.screenshot()](https://pptr.dev/api/puppeteer.page.screenshot)
3. Call the API
Use URL encoding for the destination URL because its query string may contain reserved characters.
cURL
curl --fail-with-body --get 'http://localhost:3000/screenshot' \
--data-urlencode 'url=https://example.com' \
--data-urlencode 'type=webp' \
--data-urlencode 'width=1440' \
--data-urlencode 'height=900' \
--output page.webp
Python
import requests
response = requests.get(
'http://localhost:3000/screenshot',
params={
'url': 'https://example.com',
'type': 'png',
'fullPage': 'true',
},
timeout=45,
)
response.raise_for_status()
with open('page.png', 'wb') as image_file:
image_file.write(response.content)
Node.js
const query = new URLSearchParams({
url: 'https://example.com',
type: 'jpeg',
quality: '82',
});
const response = await fetch(`http://localhost:3000/screenshot?${query}`);
if (!response.ok) {
throw new Error(`Screenshot failed: ${response.status} ${await response.text()}`);
}
const image = Buffer.from(await response.arrayBuffer());
await import('node:fs/promises').then(({ writeFile }) => writeFile('page.jpg', image));
4. Choose capture behavior
| Need | Request option / Puppeteer setting | Behavior |
|---|---|---|
| Initial viewport | Default | Capture the visible page area at the configured viewport. |
| Entire document | fullPage=true / fullPage |
Capture beyond the initial viewport. Long pages can produce large images and use more memory. |
| Specific rectangle | clip |
Capture a defined x/y/width/height region. Validate finite positive dimensions and bounds before accepting it. |
| Format | type |
PNG is Puppeteer’s documented default; JPEG and WebP can be requested where supported. |
| Lossy image quality | quality |
Use for JPEG or WebP; quality does not apply to PNG. |
| Transparent background | omitBackground |
Omit the default background when the page has a transparent background; most useful with PNG. |
| Save to local file | path |
Puppeteer can write to a path instead of only returning bytes. In an API, prefer an application-controlled temporary path if persistence is needed. |
The small example exposes format, quality, transparency, viewport size, and full-page capture. To support clipped captures, define a JSON POST body such as {"clip":{"x":0,"y":0,"width":800,"height":600}}, validate every field, then pass the validated object as clip. Do not expose arbitrary filesystem paths through a path parameter. The official guide also documents ElementHandle.screenshot() for capturing one selected element; a production endpoint that adds selectors should define selector length and wait behavior explicitly. [Puppeteer screenshot options](https://pptr.dev/guides/screenshots)
5. Browser lifecycle and concurrency
This implementation launches one browser process lazily and creates a fresh page per request. Closing each page in finally releases page resources even when navigation or capture fails. The browser is closed during process shutdown. A production service should also account for unexpected browser exits and recreate the process; the sample does not implement a browser restart supervisor.
Do not assume unlimited concurrency is safe. Each active page consumes browser and application resources, while large full-page captures can produce substantial buffers. Place a bounded queue or semaphore in front of capture work, enforce an overall request deadline, and cap viewport and output dimensions. Return a clear overload response when saturated rather than accepting unbounded work. These are service design choices; Puppeteer’s screenshot documentation does not prescribe a queue policy.
Navigation wait conditions trade completeness for latency. networkidle2 waits for network activity to quiet, which is useful for many static pages but may stall on pages with persistent requests. Alternatives include domcontentloaded or load, followed by a known selector wait or a short explicit delay when the target page requires it. Always keep a timeout and test the chosen behavior against the pages your service is meant to capture.
6. Security boundary for caller-provided URLs
An endpoint that browses a URL supplied by its caller is an execution boundary: the browser makes network requests on behalf of your service. The research sources do not establish a complete security policy for this feature, so the sample’s protocol check is only basic input validation, not a production security design.
- Decide whether the endpoint is private, authenticated, or publicly callable; add authentication and per-client rate limits appropriate to that decision.
- For untrusted callers, define an explicit destination policy. Consider DNS resolution, redirects, alternate IP encodings, and requests initiated by page subresources; checking only the first URL string is not a complete network boundary.
- Run the browser in an isolated environment with restricted access to internal services, credentials, and host files. Do not pass privileged application secrets into the browser environment.
- Set request, navigation, memory, CPU, output-size, and concurrency limits. Avoid logging sensitive query strings or captured page content.
- Decide how failures are represented and whether the API stores any output. The example returns bytes and sets
Cache-Control: no-store.
These are issues to resolve for the deployment context, not protections implemented by this minimal sample.
7. Docker deployment
Puppeteer’s official Docker image includes Chrome for Testing and required dependencies. The Puppeteer Docker guide’s documented sandbox-mode example uses the SYS_ADMIN capability and recommends an init process so child processes are managed. Follow that guide’s setup for the specific image and environment; container platforms differ, so do not treat the example as a universal deployment prescription. [Puppeteer Docker guide](https://pptr.dev/guides/docker)
docker run --init --cap-add=SYS_ADMIN \
--rm -p 3000:3000 \
-e PORT=3000 \
screenshot-api-image
Build and tag screenshot-api-image from the official Puppeteer image following its Docker guide, or install Puppeteer and its browser dependencies in your own image. Keep the browser and Puppeteer versions aligned; Puppeteer documents that it is guaranteed to work with its bundled browser. [Puppeteer configuration](https://pptr.dev/guides/configuration)
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser executable missing | Browser download was skipped, or runtime image lacks the expected browser. | Install with Puppeteer’s browser download enabled, use the documented image, or configure a valid executable path. |
| Navigation timeout | Slow destination, persistent network traffic, or an unsuitable wait condition. | Use a finite timeout; try a less strict wait condition, and consider waiting for a specific selector after DOM readiness. |
| Empty or partly rendered result | Application content loads after the chosen navigation event. | Wait for a page-specific selector or a documented application-ready signal before capture. |
| 400 response | Missing or malformed URL, unsupported protocol, invalid dimensions or quality. | Use an absolute HTTP(S) URL and values within the endpoint’s allowed ranges. |
| 502 response | Navigation or browser capture failed. | Inspect server logs without recording secrets, confirm the target is reachable from the container, and retry only under a bounded retry policy. |
| Container exits or child processes linger | Browser sandbox/container setup or process management differs from the documented configuration. | Review Puppeteer’s Docker guide, including its sandbox-mode example and init-process recommendation. |
| Memory spikes | Large page, full-page capture, high viewport, or too many concurrent pages. | Lower dimension limits, constrain concurrency, and reject or route unusually large captures separately. |
9. Performance, reliability, and cost
Most capture latency comes from browser startup and destination navigation. Keeping a browser process warm avoids starting Chrome for every request, while per-request pages keep independent navigation state. Reuse can improve throughput, but it also means crashes and resource leaks need operational handling. Measure your own workloads: the cited Puppeteer references do not provide performance or cost benchmarks.
For reliability, define timeouts at both the HTTP request and browser navigation layers, close pages on every path, monitor browser process health, and bound queued work. A request that times out should not leave a page or browser process accumulating in the background. If captures are not expected to finish within an interactive HTTP request, use an asynchronous job model and store results according to your application policy.
Cost depends on compute, browser memory, request volume, output storage, and how long pages take to render. A self-hosted implementation has no per-screenshot service price in this tutorial, but it does require operating the browser runtime. Set limits before opening the endpoint to broad traffic.
Or skip the browser setup
If the goal is simply to get clean website screenshots, ScreenshotNeo provides a screenshot API and MCP server. Its API accepts one GET request for a URL and returns PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed. Response headers identify the page verdict and billing status.
- An MCP server lets AI agents, including Claude, Cursor, and any MCP client, take screenshots with its tools.
- The Free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots; yearly billing gives two months free. Every feature is on every plan.
Sign up for ScreenshotNeo to get 1,000 free screenshots a month with no card.
FAQ
Should the endpoint return base64 or image bytes?
Return bytes for a normal HTTP image response. Use base64 only when a caller specifically needs text transport, since it adds encoding overhead.
Can the API capture a single page element?
Yes. Puppeteer documents ElementHandle.screenshot(). Add a selector option only with explicit validation and a defined wait strategy.
Can one service return PDFs too?
Yes, Puppeteer has PDF generation APIs, but PDF pagination and options form a separate response contract. Add a dedicated endpoint or explicit output mode rather than returning PDF bytes with an image content type.
Do I need to launch a new browser for every request?
No. The example keeps one browser process open and creates a page per request. A process-per-request model is simpler to isolate but adds browser startup overhead.


