Skip to content

Build a Screenshot Service with Puppeteer (Step-by-Step Guide)

DodaTech Updated 2026-06-21 6 min read

In this tutorial, you'll learn about Build a Screenshot Service with Puppeteer (Step. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Build a headless browser screenshot service using Node.js and Puppeteer that captures full-page screenshots of any URL with configurable viewport size, delay timing, and automatic file output.

What You'll Build

You'll build a REST API that accepts a URL and returns a full-page screenshot. Users can customize the viewport width and height, set a delay before capture (for JavaScript-rendered content), choose between PNG and JPEG format, and receive the image file in response.

Why a Screenshot Service Matters

Screenshot services are used everywhere: generating preview images for link sharing (like Slack or Twitter cards), monitoring website visual changes over time, capturing error states for bug reports, and archiving web pages. Security teams use them to inspect live pages for phishing or defacement. The same headless browser technology can automate login flows — a technique used in Durga Antivirus Pro for scanning suspicious URLs in a sandboxed environment.

Prerequisites

Step 1: Project Setup

mkdir screenshot-service
cd screenshot-service
npm init -y
npm install <a href="/backend/nodejs/">Express</a> puppeteer uuid

Project structure:

screenshot-service/
├── server.js        # Express + Puppeteer route
└── screenshots/     # Output directory for captured images

Puppeteer downloads Chromium (~300MB) on first install. If you're on a server with limited space, use PUPPETEER_SKIP_DOWNLOAD=true and point to an existing Chrome installation with executablePath.

Step 2: The Screenshot Endpoint

// server.js
const express = require("express");
const puppeteer = require("puppeteer");
const { v4: uuidv4 } = require("uuid");
const path = require("path");
const fs = require("fs");

const app = express();
app.use(express.json());

const OUTPUT_DIR = path.join(__dirname, "screenshots");
fs.mkdirSync(OUTPUT_DIR, { recursive: true });

app.post("/screenshot", async (req, res) => {
  const { url, width = 1280, height = 720, delay = 0, format = "png", fullPage = true } = req.body;

  if (!url) return res.status(400).json({ error: "url is required" });

  let browser;
  try {
    browser = await puppeteer.launch({
      headless: "new",
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
    });

    const page = await browser.newPage();
    await page.setViewport({ width, height });

    console.log(`Navigating to ${url}`);
    await page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });

    if (delay > 0) {
      console.log(`Waiting ${delay}ms for content to render`);
      await new Promise((r) => setTimeout(r, delay));
    }

    const filename = `${uuidv4()}.${format}`;
    const filepath = path.join(OUTPUT_DIR, filename);
    await page.screenshot({ path: filepath, fullPage, type: format });

    res.download(filepath, filename, () => {
      fs.unlinkSync(filepath);
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  } finally {
    if (browser) await browser.close();
  }
});

app.listen(3000, () => console.log("Screenshot service on http://localhost:3000"));

Expected output:

curl -X POST http://localhost:3000/screenshot \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com","width":1920,"height":1080,"format":"png"}' \
  --output example-screenshot.png

This downloads a 1920x1080 full-page screenshot of example.com as example-screenshot.png.

Step 3: Batch Screenshot Endpoint

// Add to server.js
app.post("/screenshot/batch", async (req, res) => {
  const { urls, width, height, delay, format } = req.body;

  if (!urls || !Array.isArray(urls)) {
    return res.status(400).json({ error: "urls must be an array" });
  }

  const browser = await puppeteer.launch({
    headless: "new",
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  });

  const results = [];
  for (const url of urls) {
    try {
      const page = await browser.newPage();
      await page.setViewport({ width: width || 1280, height: height || 720 });
      await page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });
      if (delay) await new Promise((r) => setTimeout(r, delay));

      const filename = `${Date.now()}-${uuidv4().slice(0, 8)}.${format || "png"}`;
      const filepath = path.join(OUTPUT_DIR, filename);
      await page.screenshot({ path: filepath, fullPage: true });
      results.push({ url, filename, status: "ok" });
      await page.close();
    } catch (err) {
      results.push({ url, error: err.message, status: "failed" });
    }
  }

  await browser.close();
  res.json({ results });
});

Expected output:

curl -X POST http://localhost:3000/screenshot/batch \
  -H "Content-Type: application/json" \
  -d '{"urls":["https://example.com","https://httpbin.org"],"format":"jpeg"}'

Response:

{
  "results": [
    { "url": "https://example.com", "filename": "1712345678-abc123.jpeg", "status": "ok" },
    { "url": "https://httpbin.org", "filename": "1712345679-def456.jpeg", "status": "ok" }
  ]
}

Architecture

sequenceDiagram
    participant Client
    participant Express as Express Server
    participant Puppeteer
    participant Chromium

    Client->>Express: POST /screenshot { url, width, height, delay }
    Express->>Puppeteer: puppeteer.launch()
    Puppeteer->>Chromium: Start headless browser
    Express->>Puppeteer: page.goto(url)
    Puppeteer->>Chromium: Navigate to URL
    Chromium-->>Puppeteer: Page loaded (networkidle2)
    Express->>Puppeteer: Wait delay ms
    Express->>Puppeteer: page.screenshot()
    Puppeteer-->>Express: PNG buffer
    Express-->>Client: Download response
    Express->>Puppeteer: browser.close()

Common Errors

1. Error: Failed to launch the browser process Chromium is missing system dependencies. Install required libraries: sudo apt install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libnss3 libxcomposite1 libxdamage1 libxrandr2 xdg-utils.

2. Timeout navigating to page The page may be slow, or networkidle2 never fires (common with infinite polling apps). Set a fallback timeout: await page.goto(url, { timeout: 15000, waitUntil: "domcontentloaded" }).

3. Screenshot is blank or white JavaScript-heavy sites need time to render. Increase the delay parameter to 2000-3000ms. Alternatively, wait for a specific selector: await page.waitForSelector("body.loaded").

4. Memory grows with batch requests Each browser launch is expensive. Reuse a single browser instance across requests using a Connection Pool pattern. Puppeteer's browser.createIncognitoBrowserContext() gives isolated sessions per request.

5. Sandbox errors on Docker Running Puppeteer inside Docker requires --no-sandbox flag. For better security, run the container with --cap-add=SYS_ADMIN or use puppeteer-extra-plugin-stealth.

Practice Questions

1. What does headless: "new" mean in Puppeteer? It uses the new headless mode introduced in Chrome 112+, which is closer to the full browser experience than the old headless mode. It supports extensions, PDF printing, and has a smaller performance gap.

2. Why use networkidle2 as the wait condition? It waits until there are no more than 2 network connections for at least 500ms. This ensures the page and its resources (images, fonts, API calls) are fully loaded before capturing.

3. How would you add authentication for the screenshot endpoint? Add middleware that checks an API key header: app.use("/screenshot", (req, res, next) => { if (req.headers["x-api-key"] !== process.env.API_KEY) return res.status(401).json({ error: "Unauthorized" }); next(); }).

4. Challenge: Element-specific screenshot Add a selector parameter. If provided, use page.waitForSelector(selector) and page.$(selector) to capture only that element instead of the full page. Useful for capturing specific widgets or charts.

5. Challenge: Scheduled monitoring Write a script that calls /screenshot/batch every hour with a list of monitored URLs, saves screenshots with timestamps, and alerts if a page fails to load.

FAQ

Can I use Playwright instead of Puppeteer?

Yes. Playwright supports multiple browsers (Chromium, Firefox, WebKit) and has a similar API. Replace puppeteer.launch() with chromium.launch() from <a href="/testing-qa/playwright/">Playwright</a>.

How do I handle pages that require login?

Use page.type() to fill forms, page.click() to submit, then take the screenshot. Store cookies and reuse them with page.setCookie(...cookieJson) for subsequent requests.

Is there a way to capture mobile screenshots?

Yes. Set page.setViewport({ width: 375, height: 812, isMobile: true, hasTouch: true }) for an iPhone-like viewport. Also set the User-Agent header with page.setUserAgent(...).

Next Steps

  • Add image optimization with sharp to compress screenshots before serving
  • Explore Puppeteer advanced patterns — request interception, ad blocking, API mocking
  • Try building the Link Preview Service project for another URL-processing use case
  • Learn about Docker deployment to containerize the service for production

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro