ScreenshotNeo

BlogHow-to

How to Take Full-Page Screenshots of Multiple URLs with Playwright

Capture a full-page screenshot for every URL with Playwright. Set up a runnable batch script, handle failures and lazy content, and save files safely.

By the ScreenshotNeo team4 October 20269 min read

Use Playwright’s page.screenshot({ fullPage: true }) for each URL. Navigate to each URL with page.goto(), then await the screenshot before moving to the next one. For a straightforward batch, reuse one page sequentially; create a separate page per URL when you need independent page state. The runnable script below creates an output directory, writes distinct files, records HTTP error responses, continues after individual URL failures, and closes the browser reliably.

Playwright describes fullPage as capturing the full scrollable page instead of only the visible viewport. Full-page capture does not guarantee every page-specific lazy-loaded image or section has loaded; add scrolling or site-specific waits when needed. See the Page API and the Pages guide.

1. Install Playwright

This example uses Node.js and Playwright’s Chromium browser. Create a project and install the package and browser:

mkdir playwright-batch-shots
cd playwright-batch-shots
npm init -y
npm install playwright
npx playwright install chromium

Save the script below as capture.mjs, then run node capture.mjs. It creates the screenshots directory itself.

2. Capture multiple URLs sequentially

import { chromium } from 'playwright';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';

const urls = [
  'https://example.com',
  'https://playwright.dev',
];

const outputDir = path.resolve('screenshots');
const navigationTimeoutMs = 30_000;
const screenshotTimeoutMs = 30_000;

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

const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
  viewport: { width: 1440, height: 900 },
  deviceScaleFactor: 1,
});
const page = await context.newPage();
const results = [];

try {
  for (const [index, url] of urls.entries()) {
    const filename = `page-${String(index + 1).padStart(3, '0')}.png`;
    const filePath = path.join(outputDir, filename);

    try {
      const response = await page.goto(url, {
        waitUntil: 'load',
        timeout: navigationTimeoutMs,
      });

      // goto can resolve for HTTP statuses such as 404 or 500.
      const status = response?.status() ?? null;
      if (status !== null && status >= 400) {
        console.warn(`${url}: HTTP ${status}; saving the returned page anyway`);
      }

      await page.screenshot({
        path: filePath,
        fullPage: true,
        animations: 'disabled',
        timeout: screenshotTimeoutMs,
      });

      results.push({ url, filePath, status, ok: true });
      console.log(`Saved ${url} -> ${filePath} (HTTP ${status ?? 'unknown'})`);
    } catch (error) {
      results.push({ url, filePath, ok: false, error: String(error) });
      console.error(`Failed ${url}: ${error.message}`);
    }
  }
} finally {
  await context.close();
  await browser.close();
}

await writeFile(
  path.join(outputDir, 'results.json'),
  JSON.stringify(results, null, 2),
);

const failed = results.filter(result => !result.ok).length;
if (failed > 0) process.exitCode = 1;

File names use the list index instead of the URL’s path. This avoids collisions from duplicate URLs, query strings, unusual characters, or two URLs with the same final path segment. The output manifest preserves the URL-to-file mapping. If you derive filenames from URLs instead, sanitize them and still handle collisions.

3. Choose page lifecycle and isolation

Reuse one page

The script above navigates one page repeatedly. This keeps the workflow simple and avoids keeping multiple tabs open. It also means the same page and browser context persist between URLs. That can be useful when you want shared session state, but it may affect captures if the site sets cookies or if scripts leave state behind.

Create one page per URL

Pages in a BrowserContext behave like separate tabs and inherit context settings. Use a fresh page when each capture should have its own page lifecycle and cleanup:

for (const [index, url] of urls.entries()) {
  const page = await context.newPage();
  try {
    await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
    await page.screenshot({
      path: `screenshots/page-${index + 1}.png`,
      fullPage: true,
    });
  } finally {
    await page.close();
  }
}

A new page in the same context does not create a separate cookie jar. If URLs require independent cookies or other context-level state, create a new BrowserContext for each isolation boundary and close it after use. Context settings such as viewport, locale, and routes are shared by pages in that context.

4. Wait for the content you need

waitUntil: 'load' waits for the load event, but it does not prove that every application widget, image, or asynchronous request has finished. Select a readiness condition that matches the target site:

  • Use domcontentloaded when the initial document is enough and speed matters. Content added later may be absent.
  • Use load for a general page-load boundary, as in the main example.
  • Wait for a specific selector when a known element indicates that the content is ready: await page.locator('main article').waitFor({ state: 'visible', timeout: 10_000 });
  • Wait for network idle selectively. Some sites keep requests open or poll continuously, so this can wait indefinitely until timeout. A stable selector or application-specific condition is often more useful.
  • Use a bounded delay only as a last resort for known short animations or delayed content: await page.waitForTimeout(1_000); A fixed delay is slower on fast pages and can still be too short on slow ones.

Lazy-loaded images and long pages

A full-page screenshot captures the scrollable document area, but the browser or site may load below-the-fold content only after scrolling. For pages with lazy images, scroll through the document before capturing, then return to the top if the page’s scroll position affects sticky elements:

await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
await page.evaluate(async () => {
  const step = Math.max(400, window.innerHeight);
  for (let y = 0; y < document.documentElement.scrollHeight; y += step) {
    window.scrollTo(0, y);
    await new Promise(resolve => setTimeout(resolve, 150));
  }
  window.scrollTo(0, 0);
});
await page.screenshot({ path: filePath, fullPage: true });

This scrolling loop is a practical starting point, not a universal lazy-loading solution. Pages can extend as new content appears; for infinite scrolling, define a stopping rule such as a known item count or a maximum scroll count. Some pages require waiting for particular images or elements after scrolling. A very tall page may also produce a large image and take longer to render or write.

5. Configure the capture

Need Playwright setting or method Notes
Full scrollable document fullPage: true The core option for this task; its default is false.
Image format File extension or type: 'jpeg' / 'png' Supported screenshot formats include PNG, JPEG, and WebP. Use a matching file extension and type.
JPEG compression quality: 80 Applies to JPEG; choose a value appropriate for visual review versus file size.
Stable output animations: 'disabled' Disables finite animations for the capture; consider page-specific dynamic content too.
Hide or replace changing regions mask: [locator], style Use masks or an injected stylesheet to make known dynamic areas consistent.
Only a region clip Clipping changes the capture area; it is not a full document screenshot.
Control output scale Context deviceScaleFactor Higher scale can produce sharper images and larger files; keep it consistent across a batch.
Navigation behavior page.goto(url, { waitUntil, timeout }) Use explicit timeouts and a readiness condition suited to each site.

The screenshot API reference documents screenshot options. Browser, operating system, headless mode, hardware, and other environment differences can change rendered pixels. Keep the environment stable when captures are compared over time; see Playwright’s visual comparison guidance.

6. Handle HTTP errors and failed pages

A navigation failure and an HTTP error response are different cases. page.goto() does not throw solely because a server returns a valid HTTP status such as 404 or 500. The script checks response.status(), logs the status, and saves the returned page so you can decide whether to keep or skip it.

For a strict policy that skips HTTP error pages, check the status before taking the screenshot:

const response = await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
const status = response?.status() ?? 0;
if (status >= 400) {
  throw new Error(`HTTP ${status}`);
}
await page.screenshot({ path: filePath, fullPage: true });

Timeouts, DNS failures, TLS errors, browser crashes, and other navigation problems are caught per URL in the main loop. That lets the remaining URLs proceed. The results manifest records failures; the script exits with a nonzero status at the end if any URL failed, which is useful in scheduled jobs or CI.

7. Parallelize only when it helps

Sequential capture is easier to diagnose and places less simultaneous load on your machine and destination sites. If the list is large and the sites permit it, a small concurrency limit can reduce elapsed time. There is no universal safe concurrency number: page size, browser resources, network capacity, and target-site policies all matter.

Use a bounded worker pool rather than launching one page for every URL at once. Give each worker its own page, preserve unique output paths, and catch failures for each URL. Start with low concurrency, observe memory use and failure rates, and increase only when the workload remains stable. Avoid parallel requests that could overload a site or violate its access rules.

For many long pages, full-page image rendering and disk writes can become the bottleneck even after navigation finishes. Limit concurrent captures, choose an appropriate viewport and scale, and retain only the format and resolution needed for the task.

8. Visual regression testing is a different goal

If the goal is to compare a page against a checked-in baseline, use Playwright Test’s screenshot assertion, toHaveScreenshot(), rather than only writing image files. The assertion waits for two consecutive screenshots to match before comparison, which helps avoid capturing a page while it is still changing. Keep browser and operating-system conditions consistent and account for intentional dynamic regions. See the visual comparisons guide and assertions documentation.

9. Troubleshooting

Symptom Likely cause Fix
browserType.launch cannot find an executable The Playwright package is installed but its browser binary is not. Run npx playwright install chromium. In a fresh environment, install browsers as part of setup.
Invalid URL or navigation error The URL is missing a scheme, malformed, or unreachable. Use a complete URL such as https://example.com; validate inputs and record per-URL failures.
Capture fails on a slow site Navigation or screenshot exceeds the timeout. Set a longer explicit timeout for that workload, use a relevant readiness condition, and keep a failure record. Do not remove timeouts without a bounded recovery plan.
The saved page is blank or incomplete The app renders after navigation, scripts failed, or access checks blocked automation. Wait for an app-specific selector, inspect the returned status and page, and determine whether the target permits automated access.
Images below the fold are missing The site loads them only when they approach the viewport. Scroll through the page before capture, then wait for important images or selectors. Infinite scroll needs an explicit stopping rule.
Some URLs overwrite others Output names are derived from a non-unique URL part. Use a unique index or collision-resistant identifier and store the original URL in a manifest.
Pixel diffs are noisy Rendering environment, animations, fonts, dynamic content, or page data changed. Pin the browser and runtime environment, disable or mask known dynamic regions, and compare under consistent conditions.
The process exits while pages remain open Cleanup is not guaranteed on an error path. Close pages and contexts in finally blocks; close the browser in a top-level cleanup path.

10. Performance, reliability, and cost

Playwright is a self-managed browser workflow: the work consumes the machine or CI runner where Chromium runs. Runtime and output size depend on the sites, network, page length, image scale, and chosen concurrency; the documentation provides no universal throughput benchmark. Sequential capture gives a clear failure boundary and modest resource use. Bounded concurrency can improve throughput at the cost of more memory, CPU, network activity, and pressure on target sites.

For reliability, use explicit timeouts, per-URL error handling, unique output names, a results manifest, and deterministic browser settings when comparing captures. Decide how to treat HTTP error responses separately from navigation exceptions. Respect site access policies and avoid exposing credentials in URLs or logs.

Or skip the browser setup

ScreenshotNeo can capture a URL through one GET request, including a batch of up to 100 URLs per call. Use its API when you want image output without installing and operating browser binaries. 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 banners, popups, and chat widgets are removed before the shot; each cleanup step can be turned off. Bot checks, blank pages, and failed loads are never billed, and response headers say which page verdict and billing outcome applied. An MCP server lets AI agents use screenshot tools. The Free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000 screenshots.

Get 1,000 free screenshots a month with no card.

FAQ

Does fullPage: true include content below the fold?

It captures the full scrollable page extent. Content that the site has not loaded yet, including some lazy-loaded elements, may still need scrolling or a site-specific wait.

Should I create a page for every URL?

Use one page per URL for separate page lifecycles and simpler per-page cleanup. Reuse one page for a simple sequential job when shared page state is acceptable.

Will a 404 response stop the script?

Not by itself. Check the navigation response status and choose whether to save or skip that page.

Can I use these screenshots for visual tests?

Yes. For baseline comparisons, Playwright Test’s toHaveScreenshot() assertion is designed for visual comparison and waits for consecutive screenshots to stabilize.