ScreenshotNeo

BlogHow-to

How to Capture a Single-Page App with JavaScript

Capture reliable SPA screenshots with JavaScript by waiting for app-specific state, then using Playwright, Puppeteer, CDP, or ScreenshotNeo.

By the ScreenshotNeo team1 October 20267 min read

A single-page app (SPA) screenshot is captured after the browser reaches the exact rendered state you want to document. In JavaScript, the dependable workflow is: open the route with Playwright or Puppeteer, wait for an app-specific readiness signal, choose viewport, full-page, or element scope, and then call the screenshot API.

A page-load event alone does not prove that an SPA has finished rendering. The app may fetch data, replace route content, animate a component, or hydrate after navigation. Wait for a meaningful signal such as a visible heading, a loaded table, a status marker, or a network request your application controls.

1. Capture an SPA with Playwright

Install Playwright in an existing Node.js project:

npm install -D playwright
npx playwright install chromium

This complete JavaScript example navigates to a route, waits for a route-specific heading, and saves a full-page PNG:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });

  await page.goto('https://example.com/app', { waitUntil: 'domcontentloaded' });
  await page.getByRole('heading', { name: 'Dashboard' }).waitFor({ state: 'visible' });
  await page.screenshot({ path: 'dashboard.png', fullPage: true });

  await browser.close();
})();

The navigation and screenshot APIs are documented in the Playwright Page API. The exact locator must match your application.

Use a stronger readiness condition

Choose the narrowest signal that proves the state is ready:

// A route heading
await page.getByRole('heading', { name: 'Orders' }).waitFor();

// A component selector
await page.locator('[data-testid="orders-loaded"]').waitFor({ state: 'visible' });

// A known text value
await page.getByText('12 open orders').waitFor();

// An application state marker
await page.waitForFunction(() => window.appState?.ordersLoaded === true);

Prefer a stable data-testid or semantic locator over a generated class name. If the app renders an empty state legitimately, wait for the empty-state marker as well as the populated-state marker.

2. Choose viewport, full-page, or element capture

Scope Use it when Playwright example
Viewport You need exactly what a user sees in a fixed browser area. page.screenshot({ path: 'view.png' })
Full page The entire scrollable document matters. page.screenshot({ path: 'full.png', fullPage: true })
Element You need one chart, dialog, card, or component. page.locator('#chart').screenshot({ path: 'chart.png' })

Playwright documents viewport, full-page, element, format, and scale controls in its screenshots guide. Full-page images can become very tall; use them only when the document itself is the subject.

Element screenshot

const chart = page.locator('[data-testid="revenue-chart"]');
await chart.waitFor({ state: 'visible' });
await chart.screenshot({ path: 'revenue-chart.png', type: 'png' });

Predictable dimensions and output

Set the viewport explicitly. Choose PNG for lossless visual comparisons, JPEG for smaller photographic output, or WebP when your downstream tools support it. Set the screenshot scale deliberately: CSS-pixel output is easier to compare across runs, while device-pixel output preserves a retina-sized image. Keep browser version, viewport, scale, fonts, and application data consistent for visual regression.

3. Handle SPA routing, data, and animations

Direct routes and client-side navigation

If a deep link returns a server 404, configure the development or production server to serve the SPA entry document for that route. When navigation happens through a client-side click, perform the click and then wait for the destination marker:

await page.getByRole('link', { name: 'Reports' }).click();
await page.waitForURL('**/reports');
await page.locator('[data-testid="reports-ready"]').waitFor({ state: 'visible' });
await page.screenshot({ path: 'reports.png', fullPage: true });

Wait for data, not an arbitrary delay

A fixed delay can hide a race condition and still fail on a slower run. Use a selector or state marker whenever possible. If there is no reliable marker, a short delay can be a last resort:

await page.waitForTimeout(1000); // fallback only; tune for your app

Freeze motion for stable images

await page.addStyleTag({ content: `
  *, *::before, *::after {
    animation: none !important;
    transition: none !important;
    caret-color: transparent !important;
  }
` });

Disable rotating carousels, blinking cursors, timestamps, random IDs, and live counters when pixel consistency matters. Use test data and a fixed timezone.

Lazy-loaded content

Full-page capture may encounter content that loads only after scrolling. Scroll deliberately before capture, or use an application marker that confirms all sections are present:

await page.evaluate(async () => {
  await new Promise(resolve => {
    let y = 0;
    const step = 600;
    const timer = setInterval(() => {
      window.scrollBy(0, step);
      y += step;
      if (y >= document.body.scrollHeight) {
        clearInterval(timer);
        resolve();
      }
    }, 100);
  });
});
await page.waitForTimeout(300);
await page.screenshot({ path: 'lazy-content.png', fullPage: true });

4. The Puppeteer equivalent

If your project already uses Puppeteer, keep the same readiness strategy:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setViewport({ width: 1440, height: 900 });

  await page.goto('https://example.com/app', { waitUntil: 'domcontentloaded' });
  await page.waitForSelector('[data-testid="dashboard-ready"]', { visible: true });
  await page.screenshot({ path: 'dashboard.png', fullPage: true });

  const chart = await page.$('[data-testid="revenue-chart"]');
  if (!chart) throw new Error('Chart was not rendered');
  await chart.screenshot({ path: 'revenue-chart.png' });

  await browser.close();
})();

See the Puppeteer screenshot guide and Page.screenshot API for page and element options.

5. Chrome DevTools Protocol

When your project already communicates directly with Chrome DevTools Protocol, use the Page.captureScreenshot command. It is lower level than Playwright or Puppeteer, so you must manage navigation, readiness, sessions, and output encoding yourself. The protocol reference is the Chrome DevTools Protocol Page domain.

6. Authentication, cookies, and environment control

For a private SPA, create an authenticated browser context or load the required cookies before navigation. Keep secrets out of source files and logs.

const context = await browser.newContext({
  timezoneId: 'UTC',
  locale: 'en-US',
  colorScheme: 'light',
  viewport: { width: 1440, height: 900 }
});
await context.addCookies([{ name: 'session', value: process.env.SESSION_COOKIE, domain: 'example.com', path: '/' }]);
const page = await context.newPage();

Set a fixed locale, timezone, geolocation, color scheme, and user agent when those values change the rendered state. For visual tests, use the same account and deterministic backend fixtures on every run.

7. Troubleshooting

Symptom Likely cause Fix
Screenshot contains a loading spinner The script waited for navigation, not application readiness. Wait for a route-specific element or state marker.
Deep link returns 404 Server is not configured for SPA fallback. Serve the app entry document for client-side routes, or open the root route and navigate in the browser.
Element screenshot fails Selector is wrong, hidden, detached, or covered. Use a stable locator, wait for visibility, and verify the element exists before capture.
Full page misses images Images are lazy-loaded below the viewport. Scroll the document, wait for image completion, or expose an app-ready marker.
Different pixels on each run Animations, fonts, timestamps, random data, or environment differences. Disable motion, use fixed data and fonts, set viewport and timezone, and record browser versions.
Blank or partially rendered page JavaScript error, failed API request, blocked resource, or premature capture. Listen for console and page errors, inspect failed requests, and extend the readiness condition.
Capture hangs A request never settles or the page keeps streaming. Use explicit timeouts, wait for a finite application marker, and avoid treating network idle as universal proof of readiness.
Text differs between machines Missing or different fonts. Install and preload the same fonts, then wait for document.fonts.ready.
page.on('console', message => console.log('[browser]', message.type(), message.text()));
page.on('pageerror', error => console.error('[pageerror]', error));
page.on('requestfailed', request => console.error('[requestfailed]', request.url(), request.failure()?.errorText));

8. Performance, reliability, and cost

  • Reuse a browser process for a batch of captures, while creating a fresh context when cookies or isolation differ.
  • Capture an element instead of a full page when surrounding content is unnecessary; the resulting file is smaller and faster to process.
  • Use a fixed viewport and avoid unnecessary fonts, videos, ads, and third-party widgets in test environments.
  • Set explicit navigation and operation timeouts, retry transient navigation failures with a limit, and save diagnostics on failure.
  • Network-idle waits can be unreliable for apps with analytics, polling, or WebSockets. Prefer an application marker.
  • Full-page images consume more memory as document height grows. Split very long documents or capture important sections separately.
  • For visual regression, compare the same route, data, browser, viewport, scale, color scheme, locale, and timezone.
  • Self-hosted browser automation costs the compute time and maintenance of the browser runtime. A hosted API can move that setup out of your application.

9. Or skip the browser setup

ScreenshotNeo provides a one-request website screenshot API and MCP server. It can capture rendered SPA routes without you managing a browser runtime:

See the ScreenshotNeo API documentation for the available options.

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}`);

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and whether it was billed. Options include full-page and element capture, custom CSS and JavaScript, click and hide actions, waits for selectors or network idle, device and viewport settings, dark mode, custom headers and cookies, authentication, timezone and geolocation, blocked resources, caching, signed links, asynchronous jobs, webhooks, bulk capture, PDF output, and an MCP server for AI agents. There is a free tier of 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.

10. FAQ

Should I wait for load, domcontentloaded, or network idle?

Use those events as navigation boundaries, then wait for an application-specific marker. None universally proves that an SPA has reached the state you intend to capture.

How do I capture only a modal or chart?

Wait for the component locator and call Playwright’s locator screenshot or Puppeteer’s element screenshot method.

When should I use full-page capture?

Use it when the complete scrollable document is required. For a visual bug report or component image, viewport or element capture is usually more focused.

Which library should a new JavaScript project choose?

Use Playwright when you want its documented page, locator, full-page, format, and scale workflows. Use Puppeteer when it is already part of the project. Use CDP directly when your system already owns the protocol connection.