Geonode logo
Geonode Team

Geonode Team

Updated: September 2, 2026

Published: 2026-09-02

How to Take a Screenshot with Playwright

`await page.screenshot({ path: 'shot.png' })` takes a screenshot. That is the easy part, and it is also where most guides stop. The harder part is making screenshots that are the same twice — which matters enormously if you are comparing them, and matters not at all if you are just looking at one. This guide covers both, plus the options that control what gets captured and the case where screenshots are genuinely the right verification tool.

Our interest, declared: we are Geonode and we sell proxies, and screenshots through a proxy are one of the cleaner use cases for our product — verifying what a page actually looks like from another country is something no API tells you. The honest caveat is the cost: a browser fetches every image, font, script and video preload, so screenshot work is the most bandwidth-hungry thing you can do on metered traffic. There is a section below on cutting that, and the technique in it will save you more money than choosing a cheaper provider would.

The Three Kinds of Screenshot

Viewport — what is currently visible, the default:

await page.screenshot({ path: 'viewport.png' });

Full page — the documentation describes it as "a screenshot of a full scrollable page, as if you had a very tall screen and the page could fit it entirely":

await page.screenshot({ path: 'full.png', fullPage: true });

Element — "Sometimes it is useful to take a screenshot of a single element":

await page.getByRole('article').screenshot({ path: 'element.png' });

Choosing between them is mostly about what you will do with the result. Viewport screenshots answer "what does the user see first". Full-page screenshots answer "what is on this page". Element screenshots answer "does this component look right", and they are the most stable of the three for comparison because they exclude everything you did not ask about.

Full-Page Screenshots and Where They Break

fullPage: true is the option people reach for and the one with the most caveats.

Lazy-loaded content may not be there. Playwright scrolls to capture, but images and components that load on intersection may not have finished when the capture completes. The reliable fix is to scroll deliberately and wait for the content you expect:

await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await expect(page.getByRole('img').last()).toBeVisible();
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: 'full.png', fullPage: true });

Sticky headers repeat or float oddly. Elements with position: fixed or sticky behave unpredictably in a stitched capture. The style option — a "CSS string to inject into the page for styling during capture" — is the clean fix:

await page.screenshot({
  path: 'full.png',
  fullPage: true,
  style: '.sticky-header { position: absolute !important; }',
});

Very long pages produce very large files. An infinite-scroll feed has no natural bottom. Consider clip to capture a defined region instead, which takes "an object which specifies clipping of the resulting image".

Overlays are captured too. Cookie banners, chat widgets and modals appear in the screenshot exactly as they appear to a user. If you do not want them, dismiss them first — and if you cannot, mask is the tool.

Element Screenshots and Buffers

Element screenshots scroll the element into view and capture only its bounding box, which makes them the right default for component-level checks.

const card = page.getByTestId('product-card').first();
await card.screenshot({ path: 'card.png' });

Two things they do not do: capture content clipped by overflow: hidden, and capture anything outside the element's box even if it visually overlaps.

Buffers instead of files. The documentation notes that "rather than writing into a file, you can get a buffer with the image and post-process it or pass it to a third party pixel diff facility". Omit path and you get the bytes back:

const buffer = await page.screenshot();
const base64 = buffer.toString('base64');

This is the form you want whenever the screenshot is going somewhere other than the local disk — an object store, an API, a report, a diffing service. It also avoids the filesystem entirely, which matters in containerised runners where the disk is ephemeral.

The Options That Make Screenshots Reproducible

If you are comparing screenshots to each other, these are not optional. If you are just eyeballing one, ignore them.

OptionValuesWhat it does
animationsdisabled, allow"When set to 'disabled', stops CSS animations during capture"
carethide, initial"When set to 'hide', hides text caret during screenshot"
maskLocator[]"Specify locators that should be masked when taking the screenshot"
maskColorCSS colour, default #F0F"Specify the color to use for masked regions"
scalecss, device"The scale of the webpage rendering"
omitBackgroundboolean, default false"Hides default white background and allows capturing transparent screenshots"
typepng, jpeg, default png"Specify the screenshot file format"
quality0–100"The quality of the image for JPEG format" — JPEG only
styleCSS stringInjected into the page for the duration of the capture

The four that solve most reproducibility problems:

animations: 'disabled' removes the single largest source of difference between two captures of the same page. Any CSS transition mid-flight produces a different pixel result each run.

mask replaces regions with a solid colour, which is how you exclude genuinely variable content — timestamps, session identifiers, personalised recommendations, adverts — without abandoning comparison entirely:

await page.screenshot({
  path: 'page.png',
  mask: [page.getByTestId('timestamp'), page.locator('.ad-slot')],
  maskColor: '#000000',
});

scale: 'css' captures at CSS pixel dimensions rather than the device pixel ratio, so a high-DPI machine and a CI runner produce comparably sized images. Set it explicitly rather than relying on a default, since this is the option most likely to differ between your laptop and the build server.

caret: 'hide' removes the blinking text cursor, which otherwise appears in roughly half your captures of any page with a focused input.

Three further things to pin down for reproducibility, none of which are screenshot options: fix the viewport size in your configuration, fix the locale and time zone, and fix the fonts — font availability differs between a developer machine and a container, and different fonts mean different layout.

Automatic Screenshots on Test Failure

The highest-value screenshot configuration in Playwright, and it requires one line:

export default defineConfig({
  use: {
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
    video: 'retain-on-failure',
  },
});

screenshot: 'only-on-failure' captures the page at the moment a test fails and attaches it to the report. Options are off, on and only-on-failure; on captures for every test and produces a lot of artefacts.

Combining it with trace is the part worth insisting on, because a trace includes DOM snapshots, network activity and every action with timings. A screenshot tells you the page looked wrong; a trace tells you why. For anything running unattended, both should be on.

This configuration is also the fastest way to diagnose the confusing category of failure where a page rendered — just not the page you expected. A challenge page, a login redirect or a regional variant all produce timeouts that look like element problems until you see the screenshot.

Visual Comparison with toHaveScreenshot

For actual visual regression testing rather than ad hoc capture:

await expect(page).toHaveScreenshot('homepage.png');
await expect(page.getByRole('navigation')).toHaveScreenshot('nav.png');

On the first run it writes a baseline; on subsequent runs it compares and fails on difference. Update baselines deliberately with --update-snapshots.

Three practical notes.

Baselines are platform-specific. Font rendering differs between operating systems, so a baseline generated on macOS will not match one generated in a Linux container. Generate baselines in the same environment your tests run in — usually CI, usually via a container you can also run locally.

Set a tolerance. Exact pixel matching produces failures for antialiasing differences that no human would notice. maxDiffPixels or maxDiffPixelRatio in your configuration is what makes the suite usable.

Mask everything variable before you start. A visual test that fails whenever a timestamp changes will be disabled within a week, which is worse than not having it.

Screenshots Through a Proxy

The case where this is genuinely our territory, and it is a good one.

Configure the proxy in your Playwright setup:

const context = await browser.newContext({
  proxy: { server: 'http://proxy.example.com:9000', username: 'u', password: 'p' },
  locale: 'de-DE',
  timezoneId: 'Europe/Berlin',
});

Note the locale and timezoneId alongside the proxy. A German exit address with an en-US locale and a London time zone is a combination no real visitor produces, and many sites use locale independently of address to decide what to serve. Set all three together or you are testing something other than what you meant to.

What this genuinely answers: what a real visitor in that country sees. Regional pricing, currency display, availability, promotional banners, whether your advertising is being delivered where you paid for it, and what appears alongside your content. No API gives you this, because the answer is a rendered page.

What it costs, and how to cut it. A browser fetches everything. On metered residential traffic — ours starts at $0.79/GB, checked September 2026 against our pricing page — a screenshot run across twenty markets adds up quickly. Blocking resource types you do not need is the single biggest lever:

await page.route('**/*.{woff,woff2,mp4,webm}', route => route.abort());

Note what is not in that list. If the screenshot is the deliverable, you cannot block images — that would defeat the point. Block fonts and media, keep images, and accept that visual verification is inherently the expensive kind of proxy work. Where you only need to confirm text content rather than appearance, block images too and skip the screenshot entirely.

And verify the geolocation actually landed. Take the screenshot and look at it. If a page captured through a Brazilian exit shows the same prices as your desk, the targeting is not working regardless of what an IP lookup reports. This is exactly the silent failure we described in why testing proxies matters — and screenshots are unusually good at catching it, because a human can see it in one glance.

Screenshots at Scale

Once you are taking more than a handful, a few practices stop the job becoming unmanageable.

Reuse the browser, not the context. Launching a browser is expensive; creating a context is cheap. For a run across many pages or many regions, launch once and create a fresh context per unit of work — that gives you isolated cookies and storage without paying the startup cost repeatedly:

const browser = await chromium.launch();
for (const country of countries) {
  const ctx = await browser.newContext({ proxy: { server: proxyFor(country) } });
  const page = await ctx.newPage();
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  await page.screenshot({ path: `shots/${country}.png`, fullPage: true });
  await ctx.close();
}
await browser.close();

Do not use networkidle as your wait condition. Pages with analytics beacons, websockets or polling never go idle, and the wait times out. Wait for the element that tells you the page is ready:

await page.goto(url);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await page.screenshot({ path: 'shot.png' });

Cap concurrency deliberately. Each browser context consumes real memory — a few hundred megabytes is normal once a page is loaded. Running thirty in parallel on a small runner produces failures that look like timeouts and are actually the machine running out of room. Start at four or five and increase while watching memory.

Name files so you can find them. A directory of screenshot-1.png through screenshot-400.png is unusable. Include the target, the region and a timestamp in the filename, and store the URL alongside the image.

Compress before archiving. PNG is lossless and large. If the images are for human review rather than pixel comparison, JPEG at quality: 80 is typically a fraction of the size and visually indistinguishable — and for a run across twenty markets on a schedule, that difference is the storage bill.

Handle failures without stopping the run. One page that will not load should not abandon the other nineteen. Wrap each capture, record the error, and continue — then report which targets failed rather than discovering the whole job died at target three.

When a Screenshot Is the Wrong Tool

When you want the data. If you need the price, extract the price. A screenshot of a number is a number you then have to read out of an image. Screenshots are for appearance; selectors are for content.

When you want to know why a test failed. A trace is strictly more informative and includes the screenshot anyway.

When the page is enormous. Full-page captures of infinite-scroll pages produce huge files that nobody will open. Clip to the region that matters.

When you are checking text. Assert on the text. expect(locator).toHaveText() gives a readable failure message; a pixel diff gives you a picture of one.

When you need it archived at scale. Screenshots are large, and thousands of them across many markets add up in storage as well as bandwidth. Store hashes or diffs and keep full images only where something changed.

People Also Ask

How do I take a screenshot in Playwright?

await page.screenshot({ path: 'shot.png' }) for the viewport, { fullPage: true } for the whole scrollable page, and locator.screenshot() for a single element. Omit path to get a buffer instead of writing a file.

How do I take a full-page screenshot?

Pass fullPage: true. Be aware that lazy-loaded content may not have arrived and that sticky or fixed elements can behave oddly in the stitched result — scroll deliberately first, and use the style option to neutralise sticky positioning during capture.

How do I screenshot a single element?

Call screenshot() on a locator rather than the page: await page.getByTestId('card').screenshot({ path: 'card.png' }). Playwright scrolls the element into view and captures its bounding box. Content clipped by overflow: hidden is not included.

How do I make Playwright screenshots consistent between runs?

Set animations: 'disabled' and caret: 'hide', mask variable regions with mask, and set scale explicitly. Then fix the viewport size, locale, time zone and available fonts, since all four affect layout and none of them are screenshot options.

How do I capture a screenshot automatically when a test fails?

Set screenshot: 'only-on-failure' in the use block of your Playwright config. Combine it with trace: 'retain-on-failure' — a trace includes DOM snapshots, network activity and action timings, which explains the failure rather than just showing it.

Can I get a screenshot as base64 instead of a file?

Yes. Omit the path option and screenshot() returns a buffer, which you can convert with buffer.toString('base64'). The docs suggest this for post-processing or passing to a pixel-diff service, and it avoids the filesystem in ephemeral CI runners.

How do I hide dynamic content from a screenshot?

Use the mask option with an array of locators, which replaces those regions with a solid colour — maskColor defaults to #F0F and can be changed. This is how you keep visual comparison useful on pages containing timestamps, session data or advertising.

Can I take screenshots through a proxy to see regional pages?

Yes, and it is one of the better uses for one. Set the proxy on the browser context, and set locale and timezoneId to match the country — many sites use locale independently of address. Then look at the resulting image to confirm the regional content actually differs, rather than trusting an IP lookup.

Wrapping Up

Taking a screenshot in Playwright is one line. Taking one that means something takes a little more.

If the screenshot is for a human to look at once — a failure artefact, a bug report, a check on what a page looks like from Brazil — the defaults are fine and screenshot: 'only-on-failure' in your config is the highest-value line you can add. Pair it with a trace, because a trace explains what a screenshot only shows.

If the screenshot is going to be compared against another screenshot, everything changes. Disable animations, hide the caret, mask the variable regions, pin the scale, and fix the viewport, locale, time zone and fonts. Then generate baselines in the same environment the tests run in, because font rendering differs across platforms and a baseline from your laptop will never match a container.

And for geographic verification — which is where a rendered page genuinely beats structured data — set the proxy, the locale and the time zone together, then look at the image to confirm the targeting worked. Bandwidth is the cost, images are the one resource type you cannot block, and that is simply what visual verification costs.

Playwright Screenshots: Full Page Elements and Reproducibility | Geonode