tutorial
Shotomatic Team
13 min read

How to Automate Website Screenshots with Puppeteer

Use Puppeteer to automate website screenshots with JavaScript. Capture one page, full-page screenshots, URL batches, and mobile viewports with a reusable script.

Laptop showing code for a Puppeteer website screenshot script

In this article, we will build a Puppeteer screenshot script in stages: first a single viewport capture, then full-page capture, a reusable URL runner, and a mobile viewport.

Puppeteer works well for this because the first script can stay small: launch a browser, open a URL, and call page.screenshot(). The official Puppeteer overview describes it as a JavaScript API for controlling Chrome or Firefox, headless by default.

The examples use Shotomatic's tools page, https://www.shotomatic.com/tools, so you can see how the same page changes across viewport, full-page, batch-ready, and mobile captures.

TL;DR: Use Puppeteer when you want screenshot automation in code. Set the viewport, wait for the page state you care about, call page.screenshot(), then add full-page capture, batching, retries, and reporting. If you only need to capture a list of public URLs without maintaining a script, a no-code workflow like Shotomatic's Website Capture may be simpler.

Disclosure: We make Shotomatic, so the examples use our public tools page as the target. You can use the same Puppeteer patterns with other public websites you are allowed to capture. If you need no-code URL-list capture instead, use Website Capture in Shotomatic.

When Puppeteer is the right tool

Puppeteer makes sense when the screenshot workflow belongs in code and the job is mostly browser control.

Use it when you want to:

  • capture public pages from a Node.js script
  • reuse Chrome's rendering behavior for screenshots
  • log in or dismiss a popup before capture
  • save viewport, full-page, PNG, JPEG, or WebP screenshots

The screenshot comes from a real headless browser, not an image library. That is the main reason Puppeteer is useful here.

Install Puppeteer for a screenshot script

For a plain Node.js project, install Puppeteer:

$ mkdir website-screenshots-puppeteer
$ cd website-screenshots-puppeteer
$ npm init -y
$ npm i puppeteer
$ mkdir scripts screenshots

The standard puppeteer package downloads a compatible Chrome for Testing during installation. Puppeteer's official installation docs also note that it downloads a chrome-headless-shell binary. If you already manage the browser yourself, or you are connecting to a remote browser, use puppeteer-core instead.

Modern package managers sometimes block install scripts. If Puppeteer is installed but the browser is missing, the official configuration docs show how to install browsers separately:

$ npx puppeteer browsers install

Capture one website screenshot

Create scripts/capture-one.mjs:

import puppeteer from "puppeteer";
import { mkdir } from "node:fs/promises";

const url = "https://www.shotomatic.com/tools";
const outputPath = "screenshots/shotomatic-tools.png";

const browser = await puppeteer.launch();
const page = await browser.newPage();

try {
  await mkdir("screenshots", { recursive: true });

  await page.setViewport({
    width: 1440,
    height: 1000,
    deviceScaleFactor: 1,
  });

  await page.goto(url, {
    waitUntil: "networkidle2",
    timeout: 45_000,
  });

  await page.screenshot({ path: outputPath });
  console.log(`Saved ${outputPath}`);
} finally {
  await browser.close();
}

Run it:

$ node scripts/capture-one.mjs

Running that script creates screenshots/shotomatic-tools.png:

Screenshot output from the Puppeteer capture-one example showing Shotomatic's tools page
Tools page output from the script above, captured at a 1440 by 1000 viewport.

Puppeteer's official screenshots guide uses the same basic flow: launch the browser, open a page, navigate to a URL, and call page.screenshot(). The path extension controls the output format unless you pass a type option directly. Puppeteer's ScreenshotOptions and ImageFormat docs cover fullPage, path, quality, and the supported png, jpeg, and webp formats.

Set the viewport before navigation. Puppeteer's page.setViewport() docs call this out because some sites behave differently when a page is resized after it loads, especially for mobile layouts.

Capture a full-page screenshot

For a full-page screenshot, pass fullPage: true. Puppeteer's ScreenshotOptions defines fullPage as the option for taking a screenshot of the full page:

await page.screenshot({
  path: "screenshots/shotomatic-tools-full-page.png",
  fullPage: true,
});

Use full-page capture when you need the whole scrollable document. Use a viewport screenshot when you care about what the page looks like at a specific screen size.

Full-page Puppeteer screenshot output of Shotomatic's free tools page
Full-page output from the same `/tools` page after the below-the-fold sections had rendered.

Full-page capture still depends on the page state. fullPage: true changes the capture area, but it does not force lazy-loaded images, animations, or in-view sections to render. The lazy-loading section below shows why the page state matters.

Build a reusable URL runner

Once one screenshot works, move the target URL into a script that can accept a list. The example still uses only /tools, so the output stays easy to follow. Later, you can add more URLs to the same array.

Create scripts/capture-batch.mjs:

import puppeteer from "puppeteer";
import { mkdir } from "node:fs/promises";

const urls = ["https://www.shotomatic.com/tools"];

const outputDir = "screenshots";

function filenameForUrl(url, index) {
  const parsed = new URL(url);
  const rawName = `${parsed.hostname}${parsed.pathname}`;

  const slug = rawName
    .replace(/^www\./, "")
    .replace(/\/$/, "")
    .replace(/[^a-z0-9]+/gi, "-")
    .replace(/^-+|-+$/g, "")
    .toLowerCase();

  return `${String(index + 1).padStart(2, "0")}-${slug || "home"}.png`;
}

async function captureUrl(browser, url, index) {
  const context = await browser.createBrowserContext();
  const page = await context.newPage();
  const outputPath = `${outputDir}/${filenameForUrl(url, index)}`;

  try {
    await page.setViewport({
      width: 1440,
      height: 1000,
      deviceScaleFactor: 1,
    });

    await page.goto(url, {
      waitUntil: "networkidle2",
      timeout: 45_000,
    });

    await page.waitForSelector("body", {
      visible: true,
      timeout: 15_000,
    });

    await page.screenshot({
      path: outputPath,
      fullPage: true,
    });

    console.log(`Captured ${url} -> ${outputPath}`);
    return { url, ok: true, outputPath };
  } catch (error) {
    console.error(`Failed ${url}: ${error.message}`);
    return { url, ok: false, error: error.message };
  } finally {
    await context.close();
  }
}

await mkdir(outputDir, { recursive: true });

const browser = await puppeteer.launch();

try {
  const results = [];

  for (const [index, url] of urls.entries()) {
    results.push(await captureUrl(browser, url, index));
  }

  const failed = results.filter((result) => !result.ok);

  if (failed.length > 0) {
    process.exitCode = 1;
    console.error(`${failed.length} screenshot(s) failed.`);
  }
} finally {
  await browser.close();
}

This keeps one browser open and creates a separate browser context for each URL. Puppeteer's BrowserContext docs describe these contexts as isolated user sessions, with separate cookies and storage. That helps when a page sets cookies or local storage during capture.

The loop runs sequentially on purpose. It is slower, but easier to debug first.

Add concurrency for bigger batches

For a handful of URLs, sequential capture is fine. For larger lists, add a small concurrency limit.

Start with 2-4 concurrent captures. A headless browser still uses CPU and memory, and aggressive batches can trigger rate limits or unstable page loads.

Add this helper:

async function runWithConcurrency(items, limit, worker) {
  const results = new Array(items.length);
  let nextIndex = 0;

  async function runNext() {
    while (nextIndex < items.length) {
      const currentIndex = nextIndex;
      nextIndex += 1;
      results[currentIndex] = await worker(items[currentIndex], currentIndex);
    }
  }

  const workers = Array.from(
    { length: Math.min(limit, items.length) },
    () => runNext(),
  );

  await Promise.all(workers);
  return results;
}

Then replace the sequential loop:

const results = await runWithConcurrency(urls, 3, (url, index) =>
  captureUrl(browser, url, index),
);

This gives the batch more throughput without letting the script open an unbounded number of pages.

Capture mobile screenshots

For a mobile-sized screenshot, set the viewport before page.goto():

await page.setViewport({
  width: 390,
  height: 844,
  deviceScaleFactor: 2,
  isMobile: true,
  hasTouch: true,
});

await page.goto("https://www.shotomatic.com/tools", {
  waitUntil: "networkidle2",
});

await page.screenshot({
  path: "screenshots/shotomatic-tools-mobile-viewport.png",
});

Use this when a rough mobile viewport is enough. Here is the same tools page captured with that viewport:

Mobile viewport Puppeteer screenshot output of Shotomatic's tools page
Mobile viewport output from Puppeteer. The file is 780 by 1688 because the script used deviceScaleFactor 2.

If you need a richer device preset, Puppeteer also exposes known devices:

import puppeteer, { KnownDevices } from "puppeteer";

const iPhone = KnownDevices["iPhone 15"];

await page.emulate(iPhone);
await page.goto("https://www.shotomatic.com/tools", {
  waitUntil: "networkidle2",
});
await page.screenshot({
  path: "screenshots/shotomatic-tools-iphone-15.png",
  fullPage: true,
});

Device emulation is more useful when viewport, user agent, touch behavior, and device scale factor all matter. Puppeteer's page.emulate() docs note that it sets both user agent and viewport, so use it before navigation.

Handle lazy-loaded content

Many pages load images or reveal sections only after they enter the viewport. If you capture the full page too early, the screenshot can preserve empty space where below-the-fold content has not appeared yet.

Full-page Puppeteer screenshot with a large blank area before lazy-loaded sections render
Captured immediately: the page height is correct, but part of the content has not rendered yet.
Full-page Puppeteer screenshot after scrolling through lazy-loaded sections
Captured after scrolling: the below-the-fold sections have had a chance to appear.

Before a full-page screenshot, scroll through the document to trigger common lazy-loading behavior:

async function triggerLazyLoading(page) {
  await page.evaluate(async () => {
    const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const viewportHeight = window.innerHeight;
    const scrollHeight = document.body.scrollHeight;

    for (let y = 0; y < scrollHeight; y += viewportHeight) {
      window.scrollTo(0, y);
      await delay(150);
    }

    window.scrollTo(0, 0);
  });
}

Use it before page.screenshot():

await page.goto(url, {
  waitUntil: "networkidle2",
  timeout: 45_000,
});

await triggerLazyLoading(page);

await page
  .waitForFunction(
    () => Array.from(document.images).every((image) => image.complete),
    { timeout: 15_000 },
  )
  .catch(() => {});

await page.screenshot({
  path: outputPath,
  fullPage: true,
});

Some pages need more than this. Custom scroll containers, animations, deferred API calls, or virtualized content can all require a page-specific selector or state.

Choose the right wait strategy

Most screenshot reliability problems come down to timing.

networkidle2 is a reasonable starting point for many screenshot scripts, and Puppeteer's own screenshot guide uses it in the basic example. Treat it as a starting point, not a promise that the page is ready. Some sites keep background requests open. Others render the main content after the network is quiet.

Common wait strategies:

  • use waitUntil: "networkidle2" for ordinary public pages
  • use page.waitForSelector() when a specific element proves the page is ready
  • use page.waitForFunction() when readiness depends on app state
  • scroll before capture when images lazy-load
  • avoid fixed sleeps as the primary wait rule

For example:

await page.goto(url, {
  waitUntil: "networkidle2",
  timeout: 45_000,
});

await page.waitForSelector("main", {
  visible: true,
  timeout: 15_000,
});

await page.screenshot({
  path: outputPath,
  fullPage: true,
});

If you maintain the target site, selector waits are usually more reliable than waiting for a generic network condition.

Add retries and a report

Real screenshot batches fail for normal reasons: temporary network errors, slow pages, redirects, rate limits, cookie banners, and third-party scripts.

Add retries around each URL:

async function withRetries(task, retries = 2) {
  let lastError;

  for (let attempt = 0; attempt <= retries; attempt += 1) {
    try {
      return await task();
    } catch (error) {
      lastError = error;
      console.warn(`Attempt ${attempt + 1} failed: ${error.message}`);
    }
  }

  throw lastError;
}

Wrap the capture work:

await withRetries(async () => {
  await page.goto(url, {
    waitUntil: "networkidle2",
    timeout: 45_000,
  });

  await page.waitForSelector("body", {
    visible: true,
    timeout: 15_000,
  });

  await page.screenshot({
    path: outputPath,
    fullPage: true,
  });
});

For jobs you will run again, write a JSON report with each URL, output path, success state, error message, and timestamp. The screenshot shows the page. The report explains the run.

A practical folder structure

For jobs you expect to rerun, separate inputs, outputs, and reports:

website-screenshots-puppeteer/
  scripts/
    capture-batch.mjs
  urls/
    product-pages.txt
    competitor-pages.txt
  screenshots/
    2026-06-23-product-pages/
    2026-06-23-competitors/
  reports/
    2026-06-23-product-pages.json

This makes the workflow easier to rerun and debug. It also keeps each screenshot set tied to the URL list that produced it.

Common mistakes

  • Capturing before the page is ready: page.goto() finishing does not always mean the content you care about has rendered. Wait for a selector or app state.
  • Setting the viewport too late: set the viewport before navigation so the page can load with the right layout from the start.
  • Treating fullPage as lazy-load handling: full-page capture changes the screenshot area. It does not guarantee that every deferred image has loaded.
  • Opening too many pages at once: start with low concurrency. Browser automation can overwhelm your machine or the target site.
  • Reusing filenames: URLs can differ only by trailing slash, query string, or path casing. Add an index and sanitize names.
  • Skipping cleanup: close contexts and the browser. Long-running jobs get messy when pages and sessions leak.

Alternatives to a Puppeteer screenshot script

Puppeteer is only one way to automate website screenshots.

If the job stays in code, Playwright is the closest alternative. It is often a better fit when screenshots are part of a larger test suite, when you need Chromium, Firefox, and WebKit coverage, or when you want Playwright Test's runner, traces, and reports around the capture work.

If you are building screenshot capture into a product, a screenshot API may save work compared with maintaining browsers yourself. Browser binaries, CI memory, retries, cleanup, and login state all become your problem once the script has to run reliably for other people.

If writing and maintaining this script is not the work you want to own, use Website Capture in Shotomatic when you want URL-list screenshots, per-page capture options, reviewable results, and exports without maintaining a Puppeteer script.

FAQ

Can Puppeteer automate website screenshots?

Yes. Puppeteer can launch a browser, open a URL, set the viewport, and save screenshots with page.screenshot().

Can Puppeteer take full-page screenshots?

Yes. Pass fullPage: true to page.screenshot() to capture the full scrollable page instead of only the visible viewport.

Can I capture multiple URLs with Puppeteer?

Yes. Keep one browser open, create a page or browser context for each URL, and add a concurrency limit for larger batches.

Is Puppeteer better than Playwright for screenshots?

Puppeteer is often simpler for Chrome-focused screenshot scripts. Playwright usually makes more sense when you need Chromium, Firefox, and WebKit coverage or a larger testing workflow.

When should I use Shotomatic instead of Puppeteer?

Use Shotomatic when the job is a no-code URL-list capture workflow that someone needs to run, review, and export without maintaining a JavaScript script.

References

The examples above were checked against the official docs:

Related posts

See more posts

How to Capture Website Screenshots in Bulk on Mac

Capture website screenshots in bulk on Mac with Shotomatic. Paste a URL list, choose desktop/tablet/mobile presets, run parallel capture, and export for audits or reports.

10 min read
Multiple laptop screens showing a digital workspace setup

Need no-code website screenshot automation?

Use Website Capture for URL-list screenshots, per-page capture options, reviewable results, and exports without maintaining a script.