ScreenshotNeo

BlogHow-to

How to Make Puppeteer Wait for a Single-Page App Before Taking a Screenshot

Wait for the app state you need—not just navigation—to capture a complete SPA screenshot. Includes selector, predicate, and network-idle patterns.

By the ScreenshotNeo team4 October 20268 min read

Wait for the condition that proves the specific content you want is ready, then take the screenshot. In Puppeteer, use page.waitForSelector() when a meaningful visible element marks readiness, or page.waitForFunction() when the app exposes a state or content condition. page.goto() lifecycle events such as networkidle2 can help, but they do not prove a single-page app (SPA) has finished rendering the view you need.

The reliable sequence is: navigate, wait for an app-specific signal with a finite timeout, capture, and close the browser in a finally block. The examples below use Puppeteer’s documented APIs for screenshots, selector waits, and page function waits.

1. Wait for a meaningful visible selector

Choose an element that appears only when the target view is rendered, such as a dashboard root, a results table, or a detail heading. Prefer a stable attribute intended for automation, such as data-testid, over a class name that changes with styling.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.goto('https://example.com/app', { waitUntil: 'domcontentloaded' });

  // Replace this with an element that identifies the view you need.
  await page.waitForSelector('[data-testid="dashboard-ready"]', {
    visible: true,
    timeout: 15_000,
  });

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

waitForSelector() resolves when the selector is present. With visible: true, Puppeteer also waits for its documented visibility condition. This is not a guarantee that every descendant, image, font, chart, or third-party widget has finished. The selected element should represent the actual state you care about, and any additional assets that matter should have their own readiness checks.

2. Wait for application-specific state

Some apps render their main container immediately, then fill it later. Others expose an explicit loading attribute, a result count, or an application state variable. In those cases, wait for the condition itself rather than the container’s existence.

await page.waitForFunction(() => {
  const root = document.querySelector('#app');
  return root?.getAttribute('data-state') === 'ready'
    && root.textContent.trim().length > 0;
}, { timeout: 15_000 });

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

Replace data-state and the predicate with a condition your application actually exposes. waitForFunction() evaluates the function in the page context until it returns a truthy value. For example, you could wait for a loading flag to become false, a known heading to appear, or the expected number of rows to render. Avoid a condition that can be true for an empty or stale view.

3. Use network idleness as a secondary signal

Navigation can wait for a lifecycle event, and Puppeteer’s screenshot guide demonstrates networkidle2. It is useful as a broad navigation heuristic, but an SPA may continue client-side work after network activity settles, or it may keep connections open while already displaying the desired content.

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

You can also call page.waitForNetworkIdle() separately. The documented default idle time is 500 ms and the default concurrency is 0; both are configurable. Persistent polling, analytics, streaming, or websocket-related activity can make a network-idle wait unsuitable. Treat it as supporting evidence, not as proof that the intended view is ready. See Puppeteer’s references for waitForNetworkIdle() and its options.

4. Choose the right readiness condition

Signal Use it when What it does not prove
Visible selector A stable element appears when the target view is rendered. That all of its content or assets are finished.
Application predicate The app exposes a loading state, result condition, or other reliable state. That unrelated visual assets have loaded.
Navigation lifecycle event You need a baseline document/navigation milestone. That client-side rendering or data loading is complete.
Network idle Network quiet is useful as one part of the wait. That the app is visually or functionally ready; persistent requests can also prevent it.
Fixed delay A short delay is needed for a known animation or transition after a stronger signal. Readiness across variable network or device conditions.

Use the narrowest strong signal available. A visible selector is straightforward when it marks the right state. A predicate is more precise when readiness is represented by data or an explicit app state. Combining a navigation milestone with one of those conditions is often practical.

5. Set timeouts and handle failures

Give each wait a finite timeout so a broken route or changed app does not leave a capture job hanging. The documented default for waitForSelector() is 30 seconds; a per-call timeout overrides it. A timeout of zero disables the timeout, which is usually a poor choice for unattended screenshot jobs. See WaitForSelectorOptions.

try {
  await page.waitForSelector('[data-testid="dashboard-ready"]', {
    visible: true,
    timeout: 15_000,
  });
  await page.screenshot({ path: 'page.png', fullPage: true });
} catch (error) {
  console.error('The target view was not ready; screenshot was not saved.', error);
  throw error;
}

In a service or batch job, catch the timeout at the job boundary, record the URL and failure reason, and decide whether to retry. Do not silently capture after a failed readiness wait: that can turn a clear failure into a misleading image of a loading shell.

6. Capture the full page or one element

Use page.screenshot() for the viewport or whole page. Set fullPage: true when the capture should include content beyond the viewport. If only one rendered component matters, wait for it and capture its element handle instead.

const card = await page.waitForSelector('[data-testid="report-card"]', {
  visible: true,
  timeout: 15_000,
});
if (!card) throw new Error('Report card was not found');
await card.screenshot({ path: 'report-card.png' });

Puppeteer documents both page screenshots and element screenshots in its screenshot guide. The element handle is scoped to the element it represents; page or frame selector waits are generally more suitable when navigation may replace page content. See the Frame.waitForSelector() and ElementHandle.waitForSelector() references for their scopes.

7. Add checks for assets that affect the image

A ready data view can still contain incomplete images or visual elements. If an image is essential, include an app signal for its load, or check the relevant image element’s complete and naturalWidth properties after the view appears. For charts, wait for the chart library’s rendered state or a stable SVG/canvas condition exposed by the app. Avoid waiting for every image on a page if unrelated lazy-loaded content can remain unloaded indefinitely.

await page.waitForFunction(() => {
  const img = document.querySelector('[data-testid="hero-image"]');
  return img instanceof HTMLImageElement && img.complete && img.naturalWidth > 0;
}, { timeout: 10_000 });

For a full-page capture with lazy-loaded images, the app may load images only as they approach the viewport. If those images must appear, use the app’s own loading mechanism or deliberately scroll through the relevant content before the final readiness check. Scrolling can trigger more network requests, so wait for the specific assets or view state afterward.

8. Common failures and fixes

Symptom Likely cause Fix
Screenshot shows the app shell or spinner The wait only covered navigation, or the selector identifies the shell. Wait for a meaningful view-specific selector or app state predicate.
TimeoutError from waitForSelector() The selector never appears, is wrong for this route, or remains hidden. Check the rendered DOM and route; choose the correct selector and visibility condition; keep a finite timeout.
Selector resolves but data is missing The element exists before its asynchronous data is populated. Wait for a nonempty value, expected row count, or explicit ready state with waitForFunction().
networkidle never completes Polling, analytics, streaming, or other persistent requests keep activity above the threshold. Use network idle only if appropriate; prefer a selector or application predicate.
Screenshot is clipped or unexpectedly short The capture defaults to the viewport or the target element was not the intended one. Use fullPage: true for the whole document or capture the correct element handle.
Images or charts are blank The view is ready but its visual assets are still loading or lazy-loaded. Add a targeted asset-ready condition or trigger lazy loading for the content you need.
Element wait fails after route change An element handle refers to a particular element and may be detached or stale. Wait again from the page or frame after navigation, then reacquire the handle.

9. Performance, reliability, and cost

  • Performance: A specific selector or predicate usually lets the capture proceed as soon as the required view is ready. A long fixed sleep delays every successful run, even when the app renders quickly.
  • Reliability: Conditions tied to the view are easier to reason about than a guessed delay. Keep selectors stable, use finite timeouts, and make failure visible to the caller.
  • Capture scope: Full-page screenshots and asset checks can take longer or use more memory than a viewport or element capture. Capture only the required area when that meets the task.
  • Cost: Self-managed Puppeteer has no per-screenshot API charge from Puppeteer itself, but the browser still consumes compute, memory, and operational time. Hosted browser services can charge according to their own plans; check the provider’s current terms before choosing one.

10. Or skip the browser setup

If you need a screenshot without installing and operating a browser, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns an image or PDF. Its capture flow accepts cookie/consent banners like a visitor and removes 60+ known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the page verdict and billing status in headers. Its MCP server gives AI agents tools for screenshots, page information, and PDF capture.

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}`);
const image = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', image));

See the ScreenshotNeo API documentation for request options. The API supports full-page capture, element selectors, viewport and device presets, custom wait conditions, headers and cookies, and more. Plans include 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000. Learn more at ScreenshotNeo, then sign up for 1,000 free screenshots a month with no card.

Frequently asked questions

Should I use waitUntil: 'networkidle2' or waitForNetworkIdle()?

Use either as a navigation or network-quiet signal when it fits the site. For an SPA, pair it with a condition tied to the intended rendered view.

Does visible: true mean the whole page is ready?

No. It means Puppeteer’s visibility condition for the selected element is met. Your app may still be loading data, images, or other content.

What timeout should I use?

Choose a finite limit that fits your application’s expected load time and job budget. Handle expiry as a failed capture instead of saving an unverified screenshot.

Can I wait for a URL change in an SPA?

A client-side route change alone does not prove that route’s content has rendered. After the route transition, wait for a selector or state condition for the destination view.