How to Compare Screenshots of a Single-Page App After Navigation
Wait for the new SPA view to be ready, then compare a stable Playwright screenshot with a reviewed visual baseline.
To compare screenshots after navigating in a single-page app (SPA), trigger the in-app action, wait for a destination-specific UI condition, and then use Playwright Test’s toHaveScreenshot() assertion. A route change does not necessarily load a new document, so waiting only for a browser navigation event can capture the old view or an intermediate state. Keep the browser environment and test data consistent with the environment used to create the baseline.
1. Set up a repeatable Playwright test
Install Playwright Test and its browser binaries in your project:
npm init playwright@latest
The setup command creates a test configuration and sample tests. The example below assumes a test file such as tests/navigation.visual.spec.ts, an app reachable at http://127.0.0.1:3000, a link labelled “Reports”, and a destination heading named “Reports overview”. Adjust the URL and accessible names to match your app.
import { test, expect } from '@playwright/test';
test('reports view matches its visual baseline after navigation', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('http://127.0.0.1:3000');
// This is an in-app action. It may change the route without loading a document.
await page.getByRole('link', { name: 'Reports' }).click();
// Wait for the destination UI, not just a URL change or load event.
const heading = page.getByRole('heading', { name: 'Reports overview' });
await expect(heading).toBeVisible();
// Compare the page with a committed reference image.
await expect(page).toHaveScreenshot('reports-overview.png', {
fullPage: true,
animations: 'disabled',
});
});
Run the test once with snapshot updating enabled to create the reference image, review it, then commit it with the test:
npx playwright test tests/navigation.visual.spec.ts --update-snapshots
npx playwright test tests/navigation.visual.spec.ts
The first run creates or updates the expected image; ordinary runs compare new captures against it. Treat baseline updates as code review material: inspect the changed image and confirm that a difference is an intended design change before accepting it.
2. Wait for the SPA destination state
Browser navigation events describe document-level navigation. An SPA may instead use client-side routing and update its content without loading a new document. Make the test wait for something users recognize in the destination view, such as its heading, a unique result row, or an enabled action.
// Prefer a meaningful destination condition.
await page.getByRole('link', { name: 'Reports' }).click();
await expect(page.getByRole('heading', { name: 'Reports overview' })).toBeVisible();
// If the app exposes a stable route, checking the URL can be an additional assertion.
await expect(page).toHaveURL(/\/reports$/);
A URL assertion is useful when route correctness matters, but it does not by itself prove that the destination content has rendered. Avoid arbitrary sleeps as the primary readiness check: they can waste time on fast runs and still be too short on slow runs. If the destination has a real asynchronous loading state, wait for the loading indicator to disappear or for the final content to appear.
3. Capture the page or only the changed component
Use a full-page screenshot when the overall destination layout is under test. Use a locator screenshot when the relevant contract is a specific panel or component; this reduces noise from unrelated page regions.
// Full page, including content below the viewport.
await expect(page).toHaveScreenshot('reports-full-page.png', { fullPage: true });
// A focused comparison for a component.
const chart = page.getByTestId('revenue-chart');
await expect(chart).toBeVisible();
await expect(chart).toHaveScreenshot('revenue-chart.png');
Give snapshots descriptive, stable names. If the same view depends on test data, make the data deterministic or create separate snapshots for the states that matter. A snapshot should represent one deliberate state, not whichever data happens to arrive first.
4. Control rendering differences and dynamic content
Visual output can differ across operating systems, browser versions, browser settings, hardware, power conditions, and headless mode. Playwright recommends generating and checking screenshots in the same environment. In practice, use the same browser project, operating-system image, viewport, fonts, and rendering mode for baseline creation and comparison.
Playwright’s screenshot assertion waits for two consecutive page screenshots to match before comparing. Finite animations are fast-forwarded to completion; infinite animations are canceled to their initial state during capture and then resumed. These safeguards help, but dynamic content such as clocks, rotating banners, random avatars, live counts, and asynchronously loaded images may still cause unwanted differences.
await expect(page).toHaveScreenshot('reports.png', {
fullPage: true,
animations: 'disabled',
// Allow a small amount of pixel variation only when it is understood and acceptable.
threshold: 0.2,
// Example: omit a volatile timestamp from pixel comparison.
mask: [page.getByTestId('last-updated')],
});
Choose a threshold deliberately: it is a tolerance for pixel differences, not proof that every ignored change is harmless. Masks are useful for unstable regions, but broad masks can hide real regressions. Another option is a screenshot-specific stylesheet that hides or normalizes volatile elements:
await expect(page).toHaveScreenshot('reports.png', {
stylePath: 'tests/visual-screenshot.css',
});
/* tests/visual-screenshot.css */
[data-testid="last-updated"],
.live-presence-indicator {
visibility: hidden !important;
}
Keep screenshot-only styling narrow and documented. Do not hide a region merely because it is failing; first determine whether its change is a product regression or genuinely irrelevant capture noise.
5. Configure the test environment
Set the browser and viewport consistently in Playwright’s configuration. For example, a project can pin Chromium and a desktop viewport:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://127.0.0.1:3000',
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
Keep snapshot generation and CI comparisons on the same OS and browser setup where practical. If local development and CI use different rendering environments, create and review baselines in the environment that will perform the comparisons. Use stable fixtures, fixed accounts, and predictable server responses so content changes only when the behavior under test changes.
6. Read and handle visual diffs
When an assertion fails, inspect the actual image, expected baseline, and diff. Classify the difference before changing anything:
- Intended design update: review the UI change and update the baseline with
--update-snapshots. - Regression: fix the application behavior or styling; keep the baseline unchanged.
- Capture noise: stabilize test data, wait for the correct state, or narrowly mask or normalize the volatile region.
Playwright reports visual assertion failures with image artifacts that can be inspected in the test output. Do not accept a snapshot update just to make CI green; a pixel tolerance also does not replace reviewing the changed image.
7. Complete runnable alternatives for browser automation
The Playwright Test example above is the recommended baseline workflow because its assertion manages reference images and diffs. The snippets below show the same essential sequence in other languages: perform the in-app action, wait for destination content, and capture an image. They save a screenshot but do not by themselves implement Playwright Test’s stored-baseline comparison.
Python with Playwright
import asyncio
from pathlib import Path
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": 1440, "height": 900})
await page.goto("http://127.0.0.1:3000")
await page.get_by_role("link", name="Reports").click()
await page.get_by_role("heading", name="Reports overview").wait_for(state="visible")
await page.screenshot(path="reports-overview.png", full_page=True, animations="disabled")
await browser.close()
asyncio.run(main())
Install with pip install playwright and install the browser with playwright install chromium. For comparison in Python, save a reviewed baseline and use an image-diff tool in your test suite; keep its threshold explicit and inspect reported diffs.
Node.js with Playwright
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto('http://127.0.0.1:3000');
await page.getByRole('link', { name: 'Reports' }).click();
await page.getByRole('heading', { name: 'Reports overview' }).waitFor({ state: 'visible' });
await page.screenshot({ path: 'reports-overview.png', fullPage: true, animations: 'disabled' });
await browser.close();
Install the library and browser with npm install playwright and npx playwright install chromium. Use @playwright/test when you want the built-in snapshot assertion and baseline management shown earlier.
cURL
cURL cannot drive a browser interaction or compare a baseline by itself. It can send a request to an already-addressable URL for a one-off capture; it will not reproduce a route that requires clicking through an in-app flow or authenticated browser state unless the capture service supports the necessary inputs.
curl -L "http://127.0.0.1:3000/reports" -o reports-response.html
This downloads the server response, not a screenshot. Use browser automation for the navigation and visual comparison workflow.
8. Or skip the browser setup
If the destination is publicly reachable by URL and does not require the preceding in-app interaction, [ScreenshotNeo](https://screenshotneo.com) can capture it with one GET request. It returns an image or PDF; this capture is useful for a page image, while Playwright Test remains the tool for comparing against committed test baselines.
See the ScreenshotNeo API documentation for request options. Example using cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
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)
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(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
ScreenshotNeo accepts cookie or consent banners as a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Sign up for 1,000 free screenshots a month, with no card required.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Screenshot shows the previous route | The test clicked but captured before client-side rendering completed. | Wait for a unique destination heading or content element to be visible. |
| URL changed, but the assertion still captures stale content | URL change and rendering are separate events. | Assert both the route and a destination-specific UI condition. |
| Snapshot changes between runs | Volatile content, animations, or different rendering environments. | Use stable fixtures, disable animations, wait for assets, and run in the baseline environment. Narrowly mask genuinely variable content. |
| Screenshot assertion times out | The page never reaches a stable image, a locator is missing, or a request is stuck. | Check the test report and browser errors, wait on the right condition, and investigate recurring animation or continuously changing content. |
| Large full-page diff for a small change | Content shifted or loaded at a different height, or the changed component affects layout. | Inspect expected, actual, and diff images; verify viewport and data, then use a locator screenshot if only one component is in scope. |
| Baseline differs on CI only | Operating system, browser version, fonts, or headless rendering differs from baseline generation. | Generate and compare baselines in the same controlled environment. |
| Images or charts are missing in capture | Capture happened before they loaded or data rendering completed. | Wait for a meaningful ready condition, such as the chart container and expected data state, rather than adding an unexplained fixed delay. |
| Snapshot update hides an actual bug | The changed image was accepted without review. | Review visual artifacts before using --update-snapshots; require intentional baseline changes in code review. |
10. Performance, reliability, and cost
- Keep the comparison focused. Full-page captures cover more layout but take and store more image data; locator screenshots make component-level failures easier to diagnose.
- Wait on state, not elapsed time. A destination-specific condition is both more reliable and usually faster than a large fixed sleep.
- Control concurrency and shared state. Tests that change the same account or data can affect each other’s screenshots. Isolate fixtures or serialize tests that cannot be independent.
- Expect environment sensitivity. Browser and OS changes can alter rendering; treat an environment upgrade as a baseline review event.
- Plan artifact storage. Reference images and failure diffs consume repository or CI artifact space. Keep only snapshots that protect meaningful UI behavior and retain failure artifacts according to team needs.
- Cost model. Playwright Test is an open-source test runner; the workflow described here runs in your environment, so operational cost is the browser and CI capacity and image/artifact storage you provision. Hosted visual review can help when a team needs shared review; Percy documents a Playwright integration, but evaluate its current workflow and pricing directly before adopting it.
11. Frequently asked questions
Should I wait for networkidle after an SPA route change?
Use the destination’s visible UI as the primary readiness signal. Network activity can continue in an app after the relevant view is ready, and idle traffic alone does not establish that the intended content is on screen.
Does toHaveScreenshot() create the expected image automatically?
On the first run without a baseline, Playwright reports the missing snapshot. Run with --update-snapshots to create it, review the artifact, and commit it intentionally.
Can I compare screenshots from different browsers?
You can maintain separate projects and baselines per browser, but compare each browser against its own reference generated in a matching environment. A cross-browser visual difference may be expected rendering variation rather than a regression.
Can ScreenshotNeo perform this in-app navigation and baseline assertion?
The supplied ScreenshotNeo API takes a URL and returns a capture. The route-driving and stored visual-baseline assertion in this guide are handled by Playwright Test; use the API for URL-based captures where that fits the job.
Sources
- Playwright: Visual comparisons — stored reference screenshots, review and updates, and environment consistency.
- Playwright:
toHaveScreenshot()API — assertion options and screenshot behavior. - Playwright: page screenshot API — screenshot capture options.
- Playwright: Navigations — document navigation behavior and SPA considerations.
- Percy: Playwright integration — hosted visual testing integration.


