How to Capture Bulk Screenshots with a Fixed Viewport and Device Scale Factor
Capture many pages with consistent viewport dimensions and pixel density using Playwright. Learn how to handle full-page output, readiness, retries, and failures.
To capture a batch of pages consistently, create one Playwright browser context with an explicit viewport width and height and an explicit deviceScaleFactor. Open each URL in that context and capture the same region with the same screenshot scale. Use fullPage: false for the fixed visible viewport; use fullPage: true only when you want each page’s full scrollable height.
The viewport is measured in CSS pixels. The device scale factor controls the emulated pixel density; it does not change the CSS layout width. For example, a 1280 × 800 CSS-pixel viewport at scale factor 2 can produce a 2560 × 1600 device-pixel screenshot when using scale: "device". That larger output takes more storage and processing. For predictable batch output, keep the viewport, scale factor, screenshot scale, and capture region together as a named profile.
This guide uses Playwright with Node.js. Playwright documents browser contexts as the place to set emulation options such as viewport and device scale factor, and its screenshot API documents full-page capture and CSS-pixel versus device-pixel scaling. Playwright emulation · Playwright screenshot API
1. Choose the capture profile
Decide these settings before starting a large run. Mixing profiles within a batch makes images harder to compare and increases the chance of accidental layout differences.
| Setting | Meaning | Typical choice for consistent batch output |
|---|---|---|
viewport.width, viewport.height |
The emulated browser’s visible layout area, in CSS pixels. | Set both explicitly, such as 1280 × 800. Reuse the same values for every page. |
deviceScaleFactor |
The emulated device pixel density used by the page. | Use 1 for standard pixel density or 2 for a high-density profile. Set it when creating the context. |
Screenshot scale |
Whether the image output uses CSS-pixel or device-pixel dimensions. | Use "device" when you want output pixels to reflect the device scale factor; use "css" for CSS-pixel output. |
fullPage |
Whether to capture the visible viewport or the full scrollable page. | Use false for a fixed-height viewport. Use true for a full-page artifact. |
| Readiness condition | When to consider a page ready for the screenshot. | Choose a condition based on the target pages, and add a selector or application-specific wait if needed. |
Viewport and full-page are different outcomes. A fixed viewport gives each image the same CSS layout area and height. A full-page capture can produce a different image height for every URL, even though the viewport width and device scale factor remain fixed.
Playwright’s screenshot API distinguishes "css" and "device" scale. At device scale, high-DPI output may be twice as large in each dimension or larger, increasing pixel count and file size. Pick the output scale based on how the images will be reviewed or processed, not just on the emulated device setting.
2. Capture a URL list with Playwright
Install Playwright and its Chromium browser in your project:
npm install playwright
npx playwright install chromium
Save the following as capture-batch.mjs. It reads one URL per line from urls.txt, uses one browser and one fixed-profile context, saves successful images under screenshots/, and records per-URL failures in capture-errors.jsonl. The example uses Node.js built-in modules and Playwright.
import { chromium } from 'playwright';
import { mkdir, readFile, writeFile, appendFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
const profile = {
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 2,
};
const screenshotOptions = {
fullPage: false,
scale: 'device',
type: 'png',
};
const inputFile = process.argv[2] ?? 'urls.txt';
const outputDir = process.argv[3] ?? 'screenshots';
const urls = (await readFile(inputFile, 'utf8'))
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
await mkdir(outputDir, { recursive: true });
await writeFile('capture-errors.jsonl', '');
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext(profile);
try {
for (const [index, url] of urls.entries()) {
const page = await context.newPage();
try {
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
if (!response) {
throw new Error('Navigation returned no main-resource response');
}
if (response.status() >= 400) {
throw new Error(`Main resource returned HTTP ${response.status()}`);
}
// Replace or supplement this with a selector that signals readiness
// for the pages being captured. Avoid a fixed delay unless necessary.
await page.locator('body').waitFor({ state: 'visible', timeout: 10000 });
const key = createHash('sha256').update(url).digest('hex').slice(0, 12);
const filename = `${String(index + 1).padStart(4, '0')}-${key}.png`;
await page.screenshot({
...screenshotOptions,
path: `${outputDir}/${filename}`,
});
console.log(`OK\t${url}\t${filename}`);
} catch (error) {
const record = {
index,
url,
error: error instanceof Error ? error.message : String(error),
};
await appendFile('capture-errors.jsonl', `${JSON.stringify(record)}\n`);
console.error(`FAIL\t${url}\t${record.error}`);
} finally {
await page.close();
}
}
} finally {
await context.close();
await browser.close();
}
Put URLs in urls.txt, one per line, then run:
node capture-batch.mjs urls.txt screenshots
The example deliberately uses domcontentloaded followed by a visible-body check rather than treating one network event as a universal guarantee. Pages with client-rendered content, slow images, or late API responses need an application-specific readiness condition. Add a wait for a known result, such as await page.locator('[data-report-ready="true"]').waitFor(), if the target site exposes one.
For a full-page capture, change fullPage to true. To emit CSS-pixel dimensions, use scale: 'css'. To use a standard-density profile, set deviceScaleFactor: 1. Keep output extension and screenshot type aligned if switching from PNG to JPEG or another supported format.
3. Readiness, retries, and batch behavior
Choose a wait condition that matches the pages
There is no single readiness event that proves every site is visually complete. Common choices include:
domcontentloadedwhen the DOM is enough and you will wait on a specific element afterward.loadwhen the page’s load event is a useful signal for the pages in the batch.networkidlefor pages that become quiet after their requests finish; analytics, polling, streaming, and long-lived connections can prevent or delay this condition.- A locator or application signal, such as a result panel becoming visible, for content rendered after the initial document event.
For lazy-loaded images, scrolling may be needed to trigger loading before a full-page screenshot. The site may also change layout as images arrive, so wait for the relevant images or content to settle when exact visual consistency matters. A fixed sleep is easy to add but either wastes time on fast pages or remains too short on slow ones.
Retry selectively
For production jobs, retry transient navigation failures and selected server errors with a small bounded retry count and backoff. Do not retry every failure indefinitely: invalid URLs, access denials, and consistent 404 responses usually need a URL or policy fix. Record the URL, attempt number, final status, and error text so a failed batch can be resumed selectively.
Write to a temporary filename and rename it after a successful screenshot if downstream processes might read the output directory while the batch is running. Keep a manifest that maps each URL to its profile, final output path, status, and capture time. The example uses a stable hash in its filenames to avoid putting URL punctuation into a path; maintain the manifest if you need a human-readable mapping.
Control concurrency and cleanup
The example captures sequentially to keep resource use and site load easy to reason about. For larger lists, bounded concurrency can reduce elapsed time, but each active page consumes browser memory and network capacity and may add load to the target sites. Start with a small concurrency limit, monitor memory and failure rates, and increase only if the environment supports it. Reuse the browser and context for a profile, but close each page in a finally block and close the context and browser at the end.
4. Chromium DevTools Protocol alternative
If the workflow is specifically Chromium-based and needs protocol-level control, the Chrome DevTools Protocol provides Emulation.setDeviceMetricsOverride for width, height, and deviceScaleFactor, followed by Page.captureScreenshot for image capture and optional clipping. This is a lower-level Chromium interface than Playwright’s context and page APIs; verify protocol compatibility against the Chromium version you deploy.
Here is the core sequence expressed as protocol calls. A CDP client must first connect to a Chromium target and enable the Page domain:
await cdp.send('Page.enable');
await cdp.send('Emulation.setDeviceMetricsOverride', {
width: 1280,
height: 800,
deviceScaleFactor: 2,
mobile: false,
});
const result = await cdp.send('Page.captureScreenshot', {
format: 'png',
captureBeyondViewport: false,
});
// result.data is base64-encoded image data.
For clipped output, pass a clip rectangle to Page.captureScreenshot. For full-page or beyond-viewport capture, use the protocol options supported by the specific Chromium version and validate resulting dimensions. The official references are Emulation.setDeviceMetricsOverride and Page.captureScreenshot.
| Approach | Best fit | Tradeoff |
|---|---|---|
| Playwright context and page API | Repeatable scripts that need a high-level browser workflow. | Playwright manages browser objects and provides a concise screenshot API. |
| CDP | Chromium-specific tooling that needs direct protocol operations. | Protocol details and available parameters can vary with Chromium versions; the caller manages more of the capture flow. |
5. Options that affect consistency
- Viewport: Keep width and height identical for the whole profile. Playwright contexts accept viewport dimensions; an individual page can override them with
page.setViewportSize(), but doing so intentionally creates a different profile for that page. - Device scale factor: Configure it at context creation. It emulates pixel density and can affect both how the page renders and the raster dimensions.
- Screenshot scale: Choose
cssordeviceoutput explicitly so that a future library default or profile edit does not silently change pixel dimensions. - Mobile emulation: Width and scale alone do not necessarily reproduce a mobile device. For a mobile profile, consider whether mobile emulation and touch behavior are part of the target; keep those settings consistent too.
- Full page versus viewport: Full-page height depends on the document. Fixed viewport captures have a bounded visible area; full-page captures do not give every image the same height.
- Image format: PNG is useful when exact edges or text clarity matter. JPEG can reduce file size for photographic content but is lossy. Pick one format for a comparison set.
- Locale and timezone: Pages may render different dates, language, prices, or content by locale and timezone. Set context options when those affect the intended comparison.
- Animations and dynamic content: Animations, rotating content, timestamps, and personalized responses can differ between captures. If the API or page provides an appropriate stabilization option, apply it consistently and document it in the manifest.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Images have the same CSS layout but unexpected pixel dimensions | The screenshot scale and device scale factor are being conflated, or one is implicit. | Set both deviceScaleFactor in the context and scale in screenshot options. Check actual image dimensions on a sample. |
| Text wraps differently across pages in the same batch | Viewport width, mobile behavior, fonts, locale, or page-specific responsive layout differs. | Use the same context profile; wait for fonts and application content where needed; set locale and mobile emulation deliberately. |
Capture times out at networkidle |
The page keeps connections or requests active, or background scripts continuously fetch data. | Use a different navigation event and wait for the specific content required by the screenshot. |
| Screenshot is blank or shows a loading state | Capture occurred before client rendering or data loading completed. | Wait for a meaningful page-specific locator or readiness signal; log the final URL and response status. |
| Some images are missing in full-page shots | Lazy-loaded images may not have been requested because their sections were never brought into view, or they had not finished loading. | Scroll through the page to trigger lazy loads, then wait for relevant images before capturing. Verify this behavior on representative pages. |
| Some URLs fail while the rest succeed | Bad input, DNS/network errors, access policy, server errors, or a target timeout. | Keep failures per URL, retry only transient cases with a limit, and rerun failed inputs rather than discarding successful output. |
| Output files overwrite one another | Filenames are derived from a non-unique label or omit the URL identity. | Use a stable unique key or index and store a URL-to-file manifest. |
| Browser memory grows during a large run | Pages or contexts remain open, images are large, or concurrency exceeds available resources. | Close each page in finally, bound concurrency, and split very large batches into manageable jobs. |
7. Performance, reliability, and cost
Capture time is usually affected by navigation, page scripts, network responses, image decoding, and screenshot rasterization. No universal throughput number applies to arbitrary sites. Measure the pages and environment that matter to your workload. Reusing a browser and context avoids launching a new browser for every URL, while a sequential loop limits simultaneous resource use. Bounded concurrency trades some of that simplicity for lower elapsed time and higher resource demand.
Device-pixel output can have substantially more pixels than CSS-pixel output. A 2× scale can mean roughly four times as many pixels for the same CSS area, before considering compression and image content. That affects memory, encoding time, storage, and upload size. Use the smallest scale that meets the output’s review or processing needs.
Reliability depends on explicit readiness rules, timeouts, bounded retries, error records, and resumability. Site responses can vary with geography, authentication, personalization, rate limits, and time. Keep the capture profile and relevant environment settings in a manifest so comparisons can be reproduced. Respect the target site’s access policies and avoid sending more concurrent traffic than appropriate.
With a self-hosted Playwright or CDP workflow, direct capture cost depends on the infrastructure and engineering time you supply; this research does not provide a benchmark or a universal cost figure. Account for browser compute, storage, network transfer, maintenance, and failed-job investigation when estimating total operating cost.
8. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server for developers. A single GET request can return a PNG, JPEG, WebP, or PDF. For batches, its bulk capture option accepts up to 100 URLs per call. The API supports viewport and device presets, retina scale, full-page capture, custom wait conditions, and caching with a chosen TTL. See the ScreenshotNeo API documentation for parameters and response behavior.
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}`);
For a batch, use the bulk endpoint and options documented for your chosen capture profile. Cookie 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, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers identify the page verdict and billing status. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots.
Sign up for ScreenshotNeo’s free plan to get 1,000 screenshots a month with no card.
9. FAQ
Does deviceScaleFactor change the CSS viewport width?
No. The viewport controls the CSS layout area. Device scale factor controls emulated pixel density; output dimensions also depend on screenshot scale and whether the capture is full-page.
Can I use one context for every URL?
Yes, when URLs share the same emulation profile. Create a separate context for each distinct profile, such as desktop and mobile, so settings do not leak between groups.
Will a fixed viewport guarantee pixel-identical screenshots?
No. It fixes the emulated dimensions and density, but page content, fonts, browser version, operating system rendering, timing, and personalization can still differ.
Should I use fullPage for fixed-size screenshots?
Usually not. Use the visible viewport when all files should have a fixed capture height. Full-page mode captures the scrollable document and can produce variable image heights.


