Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
The reliable pattern is simple: start a controlled browser, create a page with the viewport you need, navigate with an explicit URL, wait for the state your screenshot requires, save the viewport, full page, or a specific element, then close the browser. The example below uses Playwright because its page API covers navigation, URL waits, viewport control, masking, animation handling, and PNG, JPEG, or WebP output.
Install Playwright and create a minimal capture
Use a current Node.js project, install Playwright, and download at least one browser binary:
mkdir site-capture
cd site-capture
npm init -y
npm install -D playwright
npx playwright install chromium
Create capture.mjs:
import { chromium } from 'playwright';
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 response = await page.goto('https://example.com', {
waitUntil: 'domcontentloaded',
timeout: 30_000
});
if (!response) {
throw new Error('The browser did not receive a navigation response');
}
if (response.status() >= 400) {
throw new Error(`HTTP failure: ${response.status()} ${response.url()}`);
}
await page.screenshot({ path: 'screenshot.png', type: 'png' });
await browser.close();
Run it with node capture.mjs. Include a scheme such as https:// in every URL. A successful navigation and a successful HTTP response are separate checks: page.goto can return a response for a 404 or 500 page without throwing, so inspect response.status() when those pages should fail your job.
Choose when navigation is complete
The right wait depends on how the page is reached and how it renders.
#1 Best Overall
Direct URL navigation
page.goto supports lifecycle waits such as domcontentloaded. For pages that continue fetching data after the initial document, wait for the visible application state you actually need instead of assuming the first lifecycle event is sufficient.
await page.goto('https://example.com/dashboard', { waitUntil: 'domcontentloaded' });
await page.locator('[data-testid="dashboard-ready"]').waitFor({ state: 'visible', timeout: 20_000 });
await page.screenshot({ path: 'dashboard.png', fullPage: true });
Navigation caused by a click
When an interaction changes the main-frame URL, wait for that URL explicitly. This avoids racing the screenshot against the click’s navigation.
await page.goto('https://example.com');
await Promise.all([
page.waitForURL('**/account', { waitUntil: 'domcontentloaded' }),
page.getByRole('link', { name: 'Account' }).click()
]);
await page.screenshot({ path: 'account.png' });
Network idle and fixed delays
A selector wait is usually more meaningful than a fixed sleep. Use a delay only for a known animation or an application with no dependable readiness marker. Network-idle waits can remain open on pages with analytics, streams, or long polling, so use them with a bounded timeout and a page-specific readiness check.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
await page.goto('https://example.com/report', { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.locator('main.report').waitFor({ state: 'visible' });
await page.waitForTimeout(300);
Capture the scope you need
Viewport screenshot
The default captures only what is visible in the current viewport:
Rank #2
- HTML CSS Design and Build Web Sites
- Comes with secure packaging
- It can be a gift option
await page.screenshot({ path: 'viewport.webp', type: 'webp', quality: 85 });
Full scrollable page
Set fullPage: true to capture the page’s full scrollable height. Very long pages can create large images; consider element captures or a deliberate viewport if the result is used in a visual test.
await page.screenshot({ path: 'full-page.png', fullPage: true });
One element
Capture a locator when headers, cards, charts, or components need independent records:
await page.locator('article.product-card').first().screenshot({
path: 'product-card.png',
animations: 'disabled'
});
Bytes instead of a file
Omit path to receive a byte buffer for hashing, diffing, object storage, or an HTTP response:
const pngBytes = await page.screenshot({ type: 'png' });
console.log(`Captured ${pngBytes.length} bytes`);
Make output deterministic
Viewport, device scale, and format
Set the viewport before navigation when responsive layout matters. A phone-sized viewport can expose site behavior that differs from desktop; configure it on the browser context rather than changing it after the page has rendered.
Rank #3
- Brand: Wiley
- Set of 2 Volumes
- A handy two-book set that uniquely combines related technologies Highly visual format and accessible language makes these books highly effective learning tools Perfect for beginning web designers and front-end developers
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 2,
colorScheme: 'dark'
});
Playwright supports PNG, JPEG, and WebP. JPEG and WebP accept a quality value where supported. scale: 'css' produces one output pixel per CSS pixel; scale: 'device' uses device pixels and can make high-DPI files larger.
await page.screenshot({
path: 'retina.webp',
type: 'webp',
quality: 80,
scale: 'css'
});
Hide, mask, or disable moving content
For repeatable captures, hide timestamps, ads, rotating promos, or personal data and mask regions whose content must not be compared literally. Disable animations when the capture is a visual assertion.
await page.screenshot({
path: 'stable.png',
animations: 'disabled',
mask: [page.locator('.live-price'), page.locator('.avatar')],
maskColor: '#777777'
});
Wait for lazy images
Full-page captures can trigger lazy loading as the page is measured, but an application-specific check is safer:
Recommended Free Tools
await page.locator('img.hero').waitFor({ state: 'visible' });
await page.evaluate(() => Promise.all(
[...document.images].map(img => img.complete
? Promise.resolve()
: new Promise(resolve => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); }))
));
Inspect structure separately
A screenshot records pixels, not accessible structure or the best way to discover controls. Use locators, accessibility snapshots, or page inspection for interaction and semantics; reserve screenshots for visual evidence.
Rank #4
Navigate authenticated and stateful pages
Use a browser context for isolated cookies, local storage, viewport, timezone, and geolocation. For repeated jobs, save and load a storage state rather than logging in for every URL. Treat authentication files as secrets and keep them out of source control.
const context = await browser.newContext({
storageState: 'auth.json',
timezoneId: 'America/New_York',
locale: 'en-US',
geolocation: { latitude: 40.7128, longitude: -74.0060 },
permissions: ['geolocation']
});
Set custom headers, cookies, a user agent, or authorization only when the target permits it and your automation is authorized. Do not attempt to bypass access controls, CAPTCHAs, or terms that prohibit automated access.
Handle failures and diagnose bad screenshots
- “Invalid URL” or immediate navigation failure: pass a complete URL such as
https://example.com, check for accidental whitespace, and verify DNS and network access from the machine running the browser. - 404 or 500 image saved: inspect the
Responsereturned bygoto; navigation success does not mean an HTTP success. - Timeout waiting for a selector: confirm the selector in the same viewport and account for iframes, authentication, consent dialogs, or a changed application state. Increase the timeout only after fixing the readiness condition.
- Click races with navigation: pair the click and
waitForURLinPromise.all, as shown above. - Blank or partially rendered capture: wait for a meaningful application selector, image completion, or a bounded network-idle period; verify that scripts were not blocked.
- Different pixels on different machines: browser version, operating system, fonts, hardware, power state, headless mode, color settings, and device scale can all change rendering. Pin the browser/runtime used by visual tests and compare in a consistent environment.
- Huge files or memory pressure: prefer WebP or JPEG where lossless PNG is unnecessary, use CSS scale, capture an element, or split an extremely long page. Close each context and browser in a
finallyblock.
let browser;
try {
browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com', { timeout: 30_000 });
await page.screenshot({ path: 'result.png' });
} finally {
await browser?.close();
}
Use screenshots in visual tests
For a one-off record, page.screenshot is enough. For regression testing, Playwright Test’s toHaveScreenshot waits for consecutive screenshots to stabilize before comparing them. Keep browser, operating-system, fonts, viewport, scale, and headless settings consistent; otherwise a visual difference may describe the environment rather than your code.
import { test, expect } from '@playwright/test';
test('home page stays visually stable', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveScreenshot('home.png', {
fullPage: true,
animations: 'disabled'
});
});
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or a PDF, while the service accepts the page as a visitor: cookie and consent banners are accepted and more than 60 known consent platforms, newsletter popups, and chat widgets are removed before capture. Each cleanup step can be turned off.
Only clean shots are billed. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and whether it was billed. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools to Claude, Cursor, and other MCP clients.
Best Value
See the ScreenshotNeo API documentation for all options. This one-call example captures Stripe:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Equivalent Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Equivalent Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
await Bun.write('shot.webp', res);
ScreenshotNeo also supports full-page captures with lazy images loaded, CSS-selector element captures, dark mode, 12 device presets or any viewport, retina scale, PDF paper and page controls, custom CSS and JavaScript, pre-capture clicks, hidden selectors, waits for selectors, delays or network idle, request/resource blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, configurable-TTL caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, an OpenAPI specification, and familiar parameter names for easier migration.
The Free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 shots; Growth is $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000. Yearly billing gives two months free, and every feature is available on every plan. Sign up free to get the 1,000 monthly screenshots without a card.
Choosing between browser automation and an API
| Need | Best fit | Reason |
|---|---|---|
| Complex clicks, custom application logic, or in-process assertions | Playwright | You control the browser, context, locators, waits, and test runner. |
| One-off or scheduled URL captures without browser maintenance | ScreenshotNeo | A single HTTP call handles rendering and cleanup, with verdict and billing headers. |
| AI-agent screenshot and page inspection workflows | ScreenshotNeo MCP | Use take_screenshot, get_page_info, and capture_pdf from an MCP client. |
| Large batches | Either, depending on control needs | Run isolated Playwright contexts or use ScreenshotNeo bulk capture for up to 100 URLs per call. |
Frequently Asked Questions
Does a screenshot prove that a page is accessible or correct?
No. It records rendered pixels. Check HTTP status, application readiness, accessibility structure, and any business assertions separately.
Should I use fullPage for every capture?
No. Use viewport captures for the visible state, element captures for components, and fullPage only when the complete scrollable document is the evidence you need.
Why do two visually identical runs differ by a few pixels?
Rendering can vary with browser and operating-system versions, fonts, hardware, headless mode, color settings, power state, and device scale. Standardize those inputs before investigating the page.
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

