How to Screenshot a Web App After Its SPA Route Change Completes
Wait for the new route and its actual content before capturing a Playwright screenshot. Learn reliable readiness checks, screenshot options, and fixes for common timing failures.
To screenshot a single-page application (SPA) after a route change, wait for the expected URL and then wait for a visible, route-specific element that proves the destination view is ready. Capture only after that condition is true. A URL can change before the app finishes rendering the new route or loading its data.
The example below uses Playwright’s JavaScript API. Replace the link name, URL pattern, and heading with values from your application.
await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('**/reports');
await page.getByRole('heading', { name: 'Reports' }).waitFor({ state: 'visible' });
await page.screenshot({ path: 'reports.png' });
See the official Page API documentation for waitForURL and Locator API documentation for locator waits. The destination heading is an example, not a universal selector: choose a condition that represents the content your screenshot needs.
1. Understand what “route change complete” means
In an SPA, a link or control can update the URL through the History API without loading a new document. The app may then fetch data, render components, and apply styles. These events happen in phases, so there is no universal browser event that means every SPA route is fully ready for every screenshot.
Playwright treats History API URL changes as navigation, so page.waitForURL() can wait for the client-side route to arrive. But URL arrival establishes the route identity, not that its content is ready. The Playwright navigation guide explains that modern pages may continue fetching data and populating the UI after load, and that readiness depends on the page and framework.
| Signal | What it confirms | Good use |
|---|---|---|
| Expected URL | The browser is on the intended route. | Confirm route identity, especially when the app updates history. |
| Visible route-specific content | The relevant view has rendered. | Wait for the heading, record name, chart, or other content to capture. |
| Application readiness marker | The app says a specific task or data load is complete. | Use when the app exposes a stable loading state or test identifier. |
| Network idle | No network connections for a defined interval. | Usually not a reliable definition of application readiness in tests. |
2. Write a reliable Playwright capture
JavaScript: wait for route and destination content
This is a complete example for a page where a Reports link navigates to /reports and the destination displays a Reports heading:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
try {
await page.goto('https://example.com');
await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('**/reports');
await page.getByRole('heading', { name: 'Reports' }).waitFor({ state: 'visible' });
await page.screenshot({ path: 'reports.png', fullPage: true });
} finally {
await browser.close();
}
})();
Install Playwright in the project and install a browser if needed using the commands in the official Playwright getting-started guide. The example assumes the page exposes an accessible link and heading. If your app uses different accessible names or content, adjust the locators to match.
Use a web-first assertion in Playwright Test
When the capture is part of a Playwright Test suite, assert on destination content before taking a direct screenshot:
const { test, expect } = require('@playwright/test');
test('captures the reports route after it renders', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('**/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
await page.screenshot({ path: 'reports.png', fullPage: true });
});
Web-first assertions retry until the condition passes or the assertion times out. This is generally more robust than checking once immediately after the click.
Choose the readiness condition deliberately
Wait for the state the screenshot actually needs. Examples include:
- A route heading becomes visible.
- A loading indicator disappears and the target content appears.
- A specific record name or result count is visible.
- A chart or component exposes a stable test ID after rendering.
If a heading appears before the screenshot’s important data, wait for the data too. If a loading message is present, you can wait for it to disappear and then wait for the result:
await page.waitForURL('**/reports');
await page.getByTestId('reports-loading').waitFor({ state: 'hidden' });
await page.getByTestId('reports-table').waitFor({ state: 'visible' });
await page.screenshot({ path: 'reports.png' });
Use selectors that are stable and meaningful to the app. A test ID can be appropriate when user-facing text is not stable; a role and accessible name are often clearer when available.
3. Pick the right screenshot scope and stability options
Viewport, full page, or one element
page.screenshot()captures the page. By default it captures the viewport; setfullPage: trueto capture the full scrollable page.locator.screenshot()captures one element. Playwright scrolls it into view and checks actionability before capture. This is useful for a component such as a report card, but it does not capture the whole page.
// Full scrollable page
await page.screenshot({ path: 'reports-full.png', fullPage: true });
// One component
await page.getByTestId('reports-table').screenshot({ path: 'reports-table.png' });
Reduce animation-related differences
For a more repeatable direct capture, disable animations when appropriate:
await page.screenshot({
path: 'reports.png',
fullPage: true,
animations: 'disabled'
});
Disabling animations can make a capture more stable, but consider whether the animation itself is what you need to inspect. Keep the viewport, browser, and relevant app state consistent when comparing captures.
Visual regression tests
For a Playwright Test visual comparison, use toHaveScreenshot() rather than saving a direct screenshot as an expected image manually. Playwright Test waits until two consecutive screenshots match before comparing the result with the expected image.
const { test, expect } = require('@playwright/test');
test('reports page matches its visual baseline', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('**/reports');
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
await expect(page).toHaveScreenshot('reports.png', { fullPage: true });
});
Screenshot assertions are a Playwright Test feature; ordinary page.screenshot() and locator.screenshot() calls capture directly. See the visual comparisons documentation for configuration and expected-image behavior.
4. Avoid weak synchronization
Why not wait only for the URL?
The URL may update synchronously while the SPA is still loading route data or rendering the destination. A screenshot immediately after waitForURL() can therefore contain a loading state, an empty container, or part of the previous view. Pair the URL wait with a content condition tied to the image you need.
Why not use a fixed sleep?
A fixed delay such as waitForTimeout(2000) does not observe the application. If the route is ready sooner, the delay wastes time; if it takes longer, the screenshot is still early. Use a visible locator or app-specific readiness signal instead.
Why not define readiness as network idle?
Playwright discourages networkidle as a testing readiness condition. Its documented threshold is no network connections for at least 500 ms, but a page can keep connections open or become network-idle before the relevant UI is ready. Prefer web assertions and locator waits that describe the desired page state.
5. Troubleshoot common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| The screenshot shows the old route. | The click did not trigger the expected navigation, or the capture ran before the route transition. | Check that the locator identifies the intended control, then wait for the expected URL and destination content. |
| The URL is correct but the page is blank or incomplete. | The route changed before asynchronous data or components rendered. | Wait for a route-specific result element or the app’s explicit ready state. |
waitForURL times out. |
The URL pattern is incorrect, the action failed, or the route uses a different path than expected. | Inspect the actual URL after the click and use a matching exact URL or glob pattern. Confirm the interaction succeeds. |
| A locator wait times out. | The selector or accessible name does not match, the content is absent, or the app is still loading. | Inspect the rendered DOM and locator match. Wait for an element that actually appears in the destination state. |
| Network-idle waiting hangs or is inconsistent. | The app has persistent network activity, or network quiet does not align with render readiness. | Replace it with an assertion on the visible destination content. |
| Captures differ between runs. | Animations, changing data, viewport differences, or asynchronous widgets alter pixels. | Fix the app state and viewport, wait for relevant content, and consider disabling animations or using a screenshot assertion. |
| The element screenshot misses content. | The capture targets one locator, not the entire page, or the element is not the intended container. | Use a page screenshot for page scope or target the correct component locator. |
6. Performance, reliability, and cost
For browser automation, the main cost of waiting is time spent on each run. A locator wait completes as soon as its condition is satisfied, while a fixed sleep always waits for its full duration. Keep timeouts long enough for realistic CI environments, but make the readiness condition specific so failures point to the route or content that did not appear.
Use deterministic test data where possible and avoid depending on unrelated widgets or third-party requests to signal readiness. A screenshot pipeline can only capture what the browser rendered; it does not make an application route finish loading. For visual regression, account for browser and rendering differences through consistent test environments and the screenshot assertion workflow.
Or skip the browser setup
If you need a screenshot of a public page rather than an authenticated, interactive SPA state, ScreenshotNeo can capture a URL with one API request. It cannot perform your app’s client-side click and custom readiness sequence, so use Playwright when that interaction or private app state is required.
See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie banners, popups, and chat widgets are removed before the shot.
- Bot checks, blank pages, and failed loads are never billed.
- An MCP server lets AI agents take screenshots.
- 1,000 screenshots a month are free with no card; paid plans start at $5 for 3,000.
Sign up free for 1,000 screenshots a month, with no card required.
FAQ
Does an SPA route change trigger a full page load?
Not necessarily. Client-side routing can update browser history and render a new view without loading a new document.
Should I wait for the new route’s heading or its data?
Wait for whichever state proves the screenshot’s important content is ready. If the heading appears before the data, wait for a result element or another app-specific signal as well.
Can I capture only the new route’s main component?
Yes. Use locator.screenshot() for one component, and page.screenshot() for the page or viewport.


