ScreenshotNeo

BlogHow-to

How to Take a Puppeteer Screenshot in an Express API Endpoint

Return a Puppeteer screenshot as an image from an Express route, with runnable code, response headers, security guidance, and troubleshooting.

By the ScreenshotNeo team4 October 20269 min read

To return a Puppeteer screenshot from an Express route, navigate a page, await page.screenshot(), wrap the returned bytes in Buffer.from(), set the response type to image/png, and send the buffer with res.send(). You do not need to save a screenshot to disk. Close the browser in a finally block so it is cleaned up after success or failure.

import express from 'express';
import puppeteer from 'puppeteer';

const app = express();

app.get('/screenshot', async (req, res, next) => {
  let browser;

  try {
    const url = req.query.url;
    if (typeof url !== 'string') {
      return res.status(400).json({ error: 'A URL is required' });
    }

    // Validate or allowlist destinations before navigating in production.
    browser = await puppeteer.launch();
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'networkidle2' });

    const bytes = await page.screenshot({ type: 'png', fullPage: true });
    res.type('png').send(Buffer.from(bytes));
  } catch (error) {
    if (res.headersSent) return next(error);
    next(error);
  } finally {
    if (browser) await browser.close();
  }
});

app.listen(3000, () => console.log('Listening on port 3000'));

Install the dependencies with npm install express puppeteer. The example uses ES modules; set "type": "module" in package.json or save it as an .mjs file. For a CommonJS project, replace the imports with const express = require('express') and const puppeteer = require('puppeteer'). The Puppeteer screenshot API and Express response behavior are documented in the Puppeteer screenshot guide, Page.screenshot API, and Express response API.

1. How the endpoint returns an image

  1. Express reads the requested destination from the query string.
  2. Puppeteer launches a browser and creates a page.
  3. page.goto() navigates to the destination and waits for the configured page state.
  4. page.screenshot() returns image bytes as a Uint8Array by default.
  5. Buffer.from(bytes) makes a Node.js Buffer, which Express can send as a binary response.
  6. res.type('png') sets the MIME type and res.send() sends the image body.
  7. The finally block closes the browser whether the request succeeded or threw an error.

Without a screenshot path, Puppeteer returns the bytes directly. Set a path only if you also need a persistent file. The endpoint above sends a single in-memory response; Express sets its content length for this kind of response.

2. Request and response example

Start the server, then request a capture with a URL-encoded destination:

curl --get 'http://localhost:3000/screenshot' \
  --data-urlencode 'url=https://example.com' \
  --output example.png

The response is binary image data. The route labels it image/png; a browser can display it directly, and a client can save it as a PNG. If the URL is absent or is not a single string, the route returns HTTP 400 with a JSON error.

3. Choose when navigation is ready

The waitUntil setting determines when goto() resolves and capture can proceed. Choose a condition that matches the page and the endpoint’s time budget:

Condition Use Trade-off
load Wait for the page load event. Some pages continue fetching content after load.
domcontentloaded Capture a page that is useful once its initial HTML is parsed. Images, fonts, or later scripts may not be ready.
networkidle0 Wait until there are no active network connections for the idle period. Analytics, polling, and other long-lived requests can prevent this state.
networkidle2 Wait until at most two network connections remain idle for the idle period. It can still be slow or unsuitable for sites with ongoing activity.

For a page that renders its main content after navigation, wait for a meaningful selector as a separate step:

await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('main article', { timeout: 10000 });
const bytes = await page.screenshot({ type: 'png', fullPage: true });

You can also wait a fixed number of milliseconds when the page has a known delayed transition, but selector-based waiting is usually more targeted. Avoid assuming that a particular navigation event guarantees every image or application widget has finished rendering.

4. Screenshot options: format, extent, and background

Need Puppeteer option Notes
PNG output type: 'png' PNG is the default and does not use the JPEG quality setting.
JPEG output type: 'jpeg', quality: 80 Quality ranges from 0 to 100; the quality option applies to JPEG, not PNG.
Viewport only Omit fullPage Captures the current viewport dimensions.
Entire document fullPage: true Captures the full page rather than only the visible viewport.
Bounded region clip: { x, y, width, height } Captures a specified rectangle; coordinates are relative to the page.
Transparent background omitBackground: true Useful when the page background should be transparent in the output.
Write to a file path: '/path/to/file.png' Optional. Omit it when the endpoint only needs to return bytes.

For JPEG, update both screenshot options and response MIME type. For example:

const bytes = await page.screenshot({ type: 'jpeg', quality: 80, fullPage: true });
res.type('jpeg').send(Buffer.from(bytes));

For transparent output, use PNG with omitBackground: true. Check the Puppeteer API documentation for option compatibility with the installed version.

5. Add viewport control and limits

A route that accepts arbitrary viewport dimensions should validate them before passing them to Puppeteer. Dimensions and full-page height affect memory use and response size.

const width = Number(req.query.width ?? 1280);
const height = Number(req.query.height ?? 800);

if (!Number.isInteger(width) || width < 320 || width > 2560 ||
    !Number.isInteger(height) || height < 240 || height > 2000) {
  return res.status(400).json({ error: 'Invalid viewport dimensions' });
}

await page.setViewport({ width, height });

Put dimension limits appropriate to your service in configuration rather than exposing unbounded values. If you need device emulation, configure the viewport and other device characteristics through Puppeteer’s page or device APIs before navigation and capture.

6. Protect a screenshot endpoint

A query parameter that controls browser navigation is an SSRF risk: a caller may try to make the server access internal services or local resources. The example’s comment is a reminder, not a complete security policy. For a public service:

  • Allowlist permitted hostnames or destinations where possible; reject loopback, private, link-local, and other non-public IP ranges.
  • Validate the URL scheme, host, and port. Usually only allow HTTP and HTTPS.
  • Re-check the destination after DNS resolution and after redirects, since a hostname may resolve to a restricted address or redirect elsewhere.
  • Limit redirects, navigation duration, page dimensions, and request body or query sizes.
  • Run the browser with least privilege and suitable network egress restrictions for your deployment.
  • Require authentication or apply rate limits if captures consume meaningful compute.
  • Do not forward secrets or internal authorization headers to destinations supplied by untrusted callers.

These are general security measures for a service that navigates to user-controlled URLs. Puppeteer’s capture documentation describes screenshot APIs, not a complete SSRF defense policy.

7. Errors and reliable response handling

Use Express error middleware so route failures become controlled server errors. Register it after the route:

app.use((err, req, res, next) => {
  console.error(err);
  if (res.headersSent) return next(err);
  res.status(500).json({ error: 'Screenshot capture failed' });
});

The route checks res.headersSent before forwarding an error because a response may already have begun. Once headers or a partial body have been sent, the server cannot safely replace it with a fresh JSON response. Do not expose raw browser errors to untrusted callers; log the details server-side and return a useful but bounded message.

Always close browser resources on errors as well as successful captures. The per-request browser pattern is straightforward, but starting a browser for every request can add startup overhead. For sustained traffic, consider a managed browser lifecycle or queue with explicit concurrency limits. Puppeteer’s documentation does not prescribe a production pooling design or deployment-specific launch flags, so choose and validate an architecture against your own workload.

8. Troubleshooting

Symptom Likely cause Fix
Response is downloaded as generic binary data The Buffer was sent without an explicit MIME type; Express uses application/octet-stream by default. Set res.type('png') or the correct type before send().
Image is corrupt or cannot be displayed Text or JSON was sent with the image, or the response was not built from screenshot bytes. Send only Buffer.from(await page.screenshot(...)) and use the matching image content type.
Capture happens before content appears The navigation condition was reached before the page’s app content or images were ready. Wait for a relevant selector or page-specific readiness condition before capture.
Navigation times out The destination is slow, unreachable, or keeps network requests active. Choose an appropriate waitUntil condition, set a bounded navigation timeout, and handle the timeout as an error.
Browser fails to launch in deployment The runtime may lack browser dependencies or have deployment-specific restrictions. Use a deployment image and launch configuration compatible with the installed Puppeteer version; inspect server logs. Do not copy launch flags without verifying their security and runtime implications.
Second response or “headers already sent” error Error handling tried to write JSON after the response had started. Check res.headersSent and delegate the error to Express when headers have already been sent.
Memory rises on large pages Full-page captures or concurrent browser pages can use substantial memory. Bound viewport and page size, cap concurrency, and avoid queuing unlimited captures.
Browser processes remain after failed requests Cleanup was skipped on an exception. Keep browser closure in finally and log cleanup failures.

9. Performance, reliability, and cost

Each capture uses browser work: launching, navigating, rendering, encoding, and transferring the image. The dossier does not provide benchmark figures, so measure latency and memory in the deployment where the endpoint will run. Track navigation failures, capture duration, response size, and concurrent browser count.

  • Reduce avoidable work: use an appropriate readiness condition instead of waiting for idle network activity when the site never becomes idle.
  • Bound resource use: set timeouts and dimension limits, cap concurrent captures, and define behavior for queue overload.
  • Choose format intentionally: PNG is the default; JPEG quality can be set from 0 to 100. Compare output quality and size for your own pages.
  • Respond from memory: skipping disk writes avoids managing temporary files when the only consumer is the HTTP client.
  • Plan for failures: a destination can fail or hang, and browser launch can fail. Return controlled errors and ensure cleanup runs.

Compute cost depends on your hosting and workload; the cited APIs give no universal per-capture cost or throughput guarantee. Estimate using observed captures, page complexity, image dimensions, and concurrency in your environment.

10. Alternative: call ScreenshotNeo instead of managing the browser

If the endpoint is mainly a way to obtain website screenshots and you do not need to operate Chromium yourself, ScreenshotNeo provides a website screenshot API and MCP server. Its one-call API can return a PNG, JPEG, WebP, or PDF. See the ScreenshotNeo API documentation.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and responses identify the page verdict and billing status in headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots, and every feature is on every plan.

Sign up for 1,000 free screenshots a month, no card required.

11. FAQ

Do I need to save the screenshot before sending it?

No. Without a path option, page.screenshot() returns image bytes that can be sent directly from memory.

Why convert the screenshot result to a Buffer?

Puppeteer returns a Uint8Array by default, and Express documents binary response handling for a Buffer. Buffer.from(bytes) bridges those APIs.

Can I return a screenshot as a data URL or Base64 string?

Puppeteer supports encoding: 'base64', which returns a string. For an image HTTP response, binary bytes with the correct MIME type are usually simpler and avoid Base64 expansion.

Which versions does this example target?

The cited Express reference is for Express 4.x and the Puppeteer documentation reports version 25.12.0. Check the APIs and runtime requirements for the versions installed in your project.