How to Capture Bulk Screenshots of Responsive Pages at Mobile and Desktop Widths
Automate mobile and desktop screenshots across a URL list with Playwright, stable filenames, reliable waits, and options for full-page or element captures.
To capture many pages at mobile and desktop widths, write a script that loops over your URL list and viewport configurations. Set the viewport before navigating, wait for the page state you intend to review, and save each screenshot with a deterministic filename that includes the route and width. Playwright provides the browser and screenshot operations; the loop, naming, waits, and error handling are your script.
This guide uses Node.js and Playwright for the main workflow, then provides a Python equivalent. It covers viewport, full-page, and element captures, repeatability, common failures, and an API option when you do not want to run a browser locally.
1. Choose routes, widths, and capture mode
First decide what the screenshots need to show. A viewport capture records what is visible without scrolling. A full-page capture creates one tall image of the scrollable page. An element capture isolates a component such as a navigation bar or product card. These outputs serve different review needs, so label them clearly.
| Decision | What to choose |
|---|---|
| URLs | List the pages or routes to review. Use complete URLs, including the correct environment and path. |
| Viewports | Choose explicit CSS viewport widths and heights based on your site’s breakpoints and review needs. There is no universal mobile and desktop pair. |
| Capture extent | Viewport, full page, or a selected element. Choose per review goal. |
| Pixel scale | CSS pixels for compact output with coordinates corresponding to CSS pixels; device pixels for higher-resolution output. |
| Readiness | Choose a site-specific selector, delay, or other state that means the content under review is ready. |
| Output | Use a stable directory and naming rule, such as home-mobile.png and home-desktop.png. |
Keep the browser, fonts, test data, authentication state, and other page conditions consistent when comparing screenshots over time. This improves repeatability, but does not guarantee pixel-identical images across environments.
2. Capture a URL and viewport matrix with Node.js
Install Playwright and its Chromium browser in your project:
npm install playwright
npx playwright install chromium
Save this as capture.mjs. Replace the example URLs, viewport dimensions, and readiness selectors with values for your site. The script creates one Chromium browser, then a fresh browser context for each viewport so viewport settings are established before navigation.
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
const pages = [
{ name: 'home', url: 'https://example.com/' },
{ name: 'pricing', url: 'https://example.com/pricing' },
{ name: 'docs-start', url: 'https://example.com/docs/start' },
];
const viewports = [
{ name: 'mobile', width: 390, height: 844 },
{ name: 'desktop', width: 1440, height: 900 },
];
const outputDir = 'screenshots';
const fullPage = false;
const readinessSelector = null; // Example: '[data-page-ready="true"]'
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const failures = [];
try {
for (const viewport of viewports) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
});
try {
const page = await context.newPage();
for (const item of pages) {
const filename = `${outputDir}/${item.name}-${viewport.name}.png`;
try {
const response = await page.goto(item.url, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
if (response && response.status() >= 400) {
throw new Error(`HTTP ${response.status()} for ${item.url}`);
}
if (readinessSelector) {
await page.locator(readinessSelector).waitFor({
state: 'visible',
timeout: 15_000,
});
}
await page.screenshot({
path: filename,
fullPage,
animations: 'disabled',
});
console.log(`Saved ${filename}`);
} catch (error) {
failures.push({ url: item.url, viewport: viewport.name, error: String(error) });
console.error(`Failed ${item.url} at ${viewport.name}: ${error}`);
}
}
} finally {
await context.close();
}
}
} finally {
await browser.close();
}
if (failures.length) {
console.error(`${failures.length} capture(s) failed.`);
process.exitCode = 1;
}
Run it with node capture.mjs. The output names are based on the explicit route name and viewport name, so they remain stable even if the order of the input list changes. Use unique route names; otherwise one capture may overwrite another.
Why the script waits this way
domcontentloaded is a navigation milestone, not a promise that every image, client-side request, or animation has finished. If the reviewed content appears later, wait for a meaningful selector, such as a page-specific heading or a test-only ready marker. A fixed delay can help with a known delayed effect, but it can also waste time or still be too short. No single readiness condition works for every site.
3. Python version
Install Playwright for Python and its Chromium browser:
python -m pip install playwright
python -m playwright install chromium
Save as capture.py and run python capture.py. This follows the same route-by-viewport loop and records individual failures while continuing through the matrix.
from pathlib import Path
from playwright.sync_api import sync_playwright
pages = [
{"name": "home", "url": "https://example.com/"},
{"name": "pricing", "url": "https://example.com/pricing"},
{"name": "docs-start", "url": "https://example.com/docs/start"},
]
viewports = [
{"name": "mobile", "width": 390, "height": 844},
{"name": "desktop", "width": 1440, "height": 900},
]
output_dir = Path("screenshots")
output_dir.mkdir(parents=True, exist_ok=True)
full_page = False
readiness_selector = None # Example: '[data-page-ready="true"]'
failures = []
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
try:
for viewport in viewports:
context = browser.new_context(
viewport={"width": viewport["width"], "height": viewport["height"]},
device_scale_factor=1,
)
try:
page = context.new_page()
for item in pages:
filename = output_dir / f'{item["name"]}-{viewport["name"]}.png'
try:
response = page.goto(
item["url"], wait_until="domcontentloaded", timeout=30_000
)
if response and response.status >= 400:
raise RuntimeError(f"HTTP {response.status} for {item['url']}")
if readiness_selector:
page.locator(readiness_selector).wait_for(
state="visible", timeout=15_000
)
page.screenshot(
path=str(filename),
full_page=full_page,
animations="disabled",
)
print(f"Saved {filename}")
except Exception as error:
failures.append((item["url"], viewport["name"], str(error)))
print(f"Failed {item['url']} at {viewport['name']}: {error}")
finally:
context.close()
finally:
browser.close()
if failures:
print(f"{len(failures)} capture(s) failed.")
raise SystemExit(1)
4. Configure capture behavior
Viewport dimensions and device scale
Use the CSS viewport dimensions that correspond to the responsive states you need to inspect. A single mobile and desktop width cannot represent every device or breakpoint. If a layout changes near a breakpoint, add a viewport just below or above that breakpoint.
Playwright recommends setting the viewport before navigation because some pages do not expect a phone-sized viewport to change after load. In the examples, each context is created with its viewport before opening a page. Set deviceScaleFactor deliberately: a value above 1 produces higher-resolution device-pixel output, while CSS-pixel scale keeps output dimensions aligned with CSS coordinates. Higher resolution also means larger image dimensions and potentially larger files.
Viewport, full-page, element, and buffer screenshots
- Viewport: omit
fullPageor set it tofalseto capture the visible area. - Full page: set
fullPage: trueto capture the full scrollable page in one image. This is a tall image, not a viewport-sized image. - Element: locate the component and call
locator.screenshot({ path: 'header.png' }). This isolates the matched element; make sure the locator matches the intended component. - Buffer: omit the
pathinpage.screenshot()and use the returned bytes for image processing or pixel comparison.
For example, a component capture in Node.js can be added after navigation:
await page.locator('header.site-header').screenshot({
path: `${outputDir}/${item.name}-${viewport.name}-header.png`,
});
Output format and deterministic names
PNG is a sensible default when lossless capture matters. Playwright also supports JPEG and WebP screenshot types. Use a filename extension that matches the selected type, and keep format consistent across comparisons. If your page names come from arbitrary URLs, derive a safe route identifier rather than putting raw query strings or slashes into filenames. Include a build or run identifier in a separate directory if you need to preserve multiple batches.
Dynamic content and visual noise
For repeatable comparisons, use stable test data and state. Disable or freeze animations when appropriate. Playwright screenshot options support style injection, which can hide known dynamic elements or mask selected regions. Apply this carefully: hiding a rotating banner may reduce noise, but hiding a component under review invalidates the result.
Authentication, cookies, and browser state
For pages requiring login, configure an authenticated browser context using your application’s test setup or a saved Playwright storage state. Keep credentials out of source code and screenshots. A fresh context per viewport isolates state; if the site depends on a login session, load the same intended state into each context. Consent banners and other overlays can affect captures, so decide whether the review should include them or use a documented test state that dismisses them.
5. Scale the batch safely
Begin with a representative subset of routes and both viewports. Confirm the breakpoints, readiness condition, output naming, and capture mode before expanding the list. A batch has one image per URL and viewport combination, so its number of outputs is the number of URLs multiplied by the number of viewport configurations.
The examples run captures sequentially. This is easier on the target site and simplifies debugging. If you introduce concurrency to reduce elapsed time, cap it, account for the load placed on your own site and machine, and keep output paths unique. The reviewed documentation does not establish a universal batch-size or performance limit.
Reuse a browser process and create contexts deliberately, as shown, instead of launching a new browser for every screenshot. Close contexts and the browser in cleanup paths. For a production visual review job, record failed URLs and viewport names, preserve logs, and make the process exit nonzero when any capture failed so automation can detect an incomplete batch.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Mobile screenshot shows desktop layout | Viewport was set after navigation, or the dimensions do not cross the site’s breakpoint. | Create the context with the intended viewport before navigation and verify the breakpoint used by the site. |
| Blank or incomplete page | Navigation milestone occurred before the relevant client-side content was ready. | Wait for a page-specific selector or readiness marker. Check the page console and network behavior when diagnosing the route. |
| Timeout during navigation | The site is slow, waiting condition is too strict, or a request never settles. | Use a suitable navigation milestone such as domcontentloaded, set a reasonable timeout for your environment, and wait separately for the content needed in the image. |
| Images or lazy content missing in full-page output | Content loads only after scrolling or intersection with the viewport. | Use the site’s expected scrolling or lazy-load behavior and wait for target content before capturing. Verify the resulting image on representative pages. |
| Files overwrite each other | Route names collide or filenames omit viewport identity. | Use unique stable route slugs and include the viewport name in every output filename. |
| Images differ between runs | Fonts, dynamic data, animations, timestamps, browser versions, or state changed. | Stabilize test data and browser conditions, disable irrelevant animations, and mask or hide only known irrelevant regions. |
| Full-page image is unexpectedly huge | The page has a long scrollable document, or device-pixel scaling increases output dimensions. | Use viewport capture when that is the review goal, or reduce the device scale factor. Capture key elements separately when a single tall image is not useful. |
| Element capture fails | The selector matches no element, matches the wrong one, or the element is not visible. | Use a specific locator and wait for it to become visible before taking its screenshot. |
| HTTP error page was saved | Navigation returned an error status while the browser still rendered a page. | Check the navigation response status and treat unexpected status codes as failures, as the examples do. |
7. Cost and operational considerations
A local Playwright workflow does not have a per-screenshot API charge in the examples; it uses your machine or CI resources. Account for the time and storage consumed by the number of URL-viewport combinations, the page weight, image dimensions, and full-page output. Retain only the artifacts needed for review, and use stable output directories so CI jobs do not mix runs.
Hosted browser infrastructure can help teams that need managed execution or broader browser coverage, but the research here does not establish provider pricing or performance. Choose based on your browser coverage, authentication needs, batch volume, artifact retention, and operational ownership.
8. Or skip the browser setup
ScreenshotNeo can capture pages through a screenshot API, including mobile and desktop viewport requests. Its API also supports full-page capture, CSS selectors, custom headers and cookies, waits, output format, caching, bulk capture, and other options. See the ScreenshotNeo API documentation for parameters and usage.
Example desktop capture with cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -d width=1440 -d height=900 -o desktop.webp
Python:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={
"access_key": "YOUR_API_KEY",
"url": "https://stripe.com",
"width": 1440,
"height": 900,
},
timeout=90,
)
r.raise_for_status()
open("desktop.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com',
width: '1440',
height: '900',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) =>
writeFile('desktop.webp', Buffer.from(await res.arrayBuffer()))
);
For a mobile capture, change the width and height to your chosen mobile viewport and use a distinct output filename. Repeat the request for each URL and viewport, or use the bulk capture option for up to 100 URLs per call.
- Cookie and consent banners are accepted like a visitor, and 60+ known consent platforms, newsletter popups, and chat widgets are removed before the shot; each step can be turned off.
- Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. Responses identify the page verdict and billing status in headers.
- An MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs.
- The free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots.
Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.
9. FAQ
Does Playwright have a bulk screenshot command?
The workflow here is a script that iterates over URLs and viewport configurations. The screenshot API captures a page; your code defines the batch.
Should I capture every breakpoint?
Capture the widths relevant to your review, especially around breakpoints where the layout changes. A pair of mobile and desktop widths is a starting point, not universal device coverage.
Can I use the same images for visual regression tests?
Yes, if your comparison process expects the chosen format, dimensions, and state. Keep capture conditions consistent and treat environment-dependent rendering differences as part of your test setup.
When should I choose full-page instead of viewport capture?
Use full-page when the below-the-fold content must appear in one artifact. Use viewport capture when the question is what a visitor sees without scrolling.


