ScreenshotNeo

BlogHow-to

How to Capture a Screenshot of a React App with Puppeteer After Hydration

Wait for an app-owned readiness signal after React hydration, then capture a reliable page or component screenshot with Puppeteer.

By the ScreenshotNeo team4 October 20268 min read

To capture a React app reliably with Puppeteer, navigate to the page, wait for an application-owned signal that says the UI needed for the screenshot is ready, and then call page.screenshot() or take a screenshot of the target element. A navigation condition such as networkidle2 can be useful, but it does not mean React hydration or later app work has finished. React does not document a general hydration-completion promise from hydrateRoot, so connect the capture to a readiness signal your app or test harness controls.

Why hydration needs its own readiness check

With server rendering, the browser can display HTML before React has attached client-side behavior. Hydration attaches component logic to that server-generated HTML so the initial snapshot becomes interactive. The server and client output are expected to match; mismatches are bugs, and React does not promise to patch every attribute difference. React hydrateRoot documentation.

page.goto() waiting for a navigation event, or for network activity to quiet, answers a different question: whether navigation has reached that condition. Puppeteer’s screenshot guide uses waitUntil: 'networkidle2' before a capture, but that is not a React readiness contract. Apps may also fetch data, render content, or schedule work after the initial navigation.

Use an app-specific signal that becomes true only after the state you want to capture is ready. For example, your app can set window.__APP_HYDRATED__ after hydration and the relevant data/rendering work. The property name and timing are your implementation choice; React does not supply this marker.

1. Add a deterministic readiness signal

Set the marker at the point your application considers the screenshot state ready. If the screenshot depends on data, a particular route, or a component finishing its client render, include those conditions in the signal. Avoid setting it merely because the first render ran if the visible result is still incomplete.

// Example: place this in app-owned client code after the state needed
// for the capture has been rendered. Adapt the condition to your app.
window.__APP_HYDRATED__ = true;

This simple example assumes the relevant work is synchronous at that point. In an application with asynchronous data or deferred UI, set the marker only after those requirements are satisfied. Keep the signal deterministic so the same test state produces the same screenshot.

2. Capture the page with Puppeteer

Install puppeteer if you want its installation to download a compatible Chrome, or use puppeteer-core when your environment manages the browser separately. Puppeteer controls Chrome or Firefox and runs headless by default. See the Puppeteer installation guide.

npm install puppeteer

Save this as an ES module, for example capture.mjs. Start your React app at the URL shown or change the URL to your environment.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();

  // Navigation readiness only; it does not prove React is hydrated.
  await page.goto('http://localhost:3000', {
    waitUntil: 'networkidle2',
    timeout: 30_000,
  });

  // The app or test harness must set this when the required UI is ready.
  await page.waitForFunction(
    () => window.__APP_HYDRATED__ === true,
    { timeout: 30_000 },
  );

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

Run it with node capture.mjs. The finally block closes the browser even if navigation, the readiness wait, or the capture fails.

3. Capture one component instead of the full page

When the deliverable is a component, wait for the target to appear and capture that element. Element presence and visibility are useful checks for the target, but they do not prove that the whole React application has hydrated. Pair them with the app readiness signal when the capture depends on broader state.

await page.waitForFunction(
  () => window.__APP_HYDRATED__ === true,
  { timeout: 30_000 },
);

const card = await page.waitForSelector('[data-testid="product-card"]', {
  visible: true,
  timeout: 10_000,
});
if (!card) throw new Error('Product card did not appear');

await card.screenshot({ path: 'product-card.png' });

Puppeteer locators can wait for an element to be present and in an appropriate state, including visibility and a stable bounding box for relevant operations. These are element-state checks rather than an app-wide hydration signal. An element screenshot may scroll the element into view; a detached element causes an error. See the Puppeteer page interactions guide and Puppeteer screenshot guide.

4. Choose screenshot options for the artifact

Need Option or method Notes
Whole document fullPage: true Captures the full page rather than only the current viewport.
Viewport only Omit fullPage Capture the current viewport; set the viewport before navigation if dimensions matter.
One component element.screenshot() Wait for the intended element and ensure it stays attached through capture.
Specific rectangle clip Use page screenshot clipping when you know the desired page coordinates and dimensions.
Image format type Puppeteer supports screenshot format options such as PNG and JPEG; select the format your consumer expects.
File output path Set a path to write the image; without it, the screenshot API returns image data.
Transparent page background omitBackground: true Useful when the page background should be transparent where supported by the selected output.

For reproducible dimensions, set the viewport explicitly before capture:

await page.setViewport({ width: 1440, height: 1000, deviceScaleFactor: 1 });

Check Puppeteer’s current ScreenshotOptions reference for the full option set supported by your installed version.

5. Use a target wait when the app has no global marker

If you cannot add a global readiness marker, make the synchronization as specific as possible to the screenshot requirement. Wait for a selector that represents the completed state, and, if needed, verify its text or attributes. A selector appearing is not proof of hydration by itself: server-rendered markup may already contain it before React attaches behavior. This fallback is strongest when the selector or state is only produced after the client work you need.

await page.waitForFunction(() => {
  const result = document.querySelector('[data-testid="results"]');
  return result?.getAttribute('data-state') === 'ready';
}, { timeout: 30_000 });

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

For a visible element wait, Puppeteer also provides locator APIs. Prefer an app-specific state assertion when visibility alone could occur before the correct content is rendered.

Common problems and fixes

Symptom Likely cause Fix
The screenshot shows a loading state or stale content Capture ran after navigation but before the required client state was ready. Set a readiness marker after the required data and render work, and wait for it before capture.
waitForFunction times out The marker was never set, its condition is wrong, or the app failed before reaching readiness. Confirm the marker exists in the page context, inspect the app error state, and ensure the signal is set on the route being captured.
networkidle2 never occurs Persistent connections or ongoing requests keep network activity above the navigation condition. Choose an appropriate navigation condition and rely on an app-owned readiness signal for the screenshot state. Do not replace readiness with an arbitrary sleep.
The target selector times out The selector is wrong, the component is conditional, or rendering failed. Check the selector and route state; wait for the actual target condition and report a useful error on timeout.
Element screenshot reports a detached node The UI replaced or removed the element between lookup and capture. Wait for the final state, reacquire the element immediately before capture, and avoid triggering a rerender between lookup and screenshot.
Hydration mismatch or unexpected page output Client output differs from server-rendered HTML, or app state changes during capture. Fix the mismatch in the application and make the capture state deterministic. React treats hydration mismatches as bugs.
Browser launch fails in deployment The environment lacks a compatible browser or required runtime setup. Use the bundled-browser behavior of puppeteer, or configure the executable and dependencies when using puppeteer-core; consult the installation guide for that environment.

Performance, reliability, and cost considerations

  • Wait for the narrowest real condition. An app-owned signal avoids both premature captures and unnecessary fixed delays. Give navigation and readiness waits finite timeouts so failures are diagnosable.
  • Keep the capture state stable. Disable or control animations and time-dependent content in the app or test setup when visual consistency matters. This is an implementation choice, not a hydration guarantee supplied by Puppeteer.
  • Choose capture scope deliberately. Full-page images can be larger and take longer to produce than a viewport or component image. Capture only the area the workflow needs.
  • Reuse browser processes for batches where appropriate. Launching a browser has setup cost; a controlled worker can reuse a browser while creating an isolated page per capture. Always close pages and browsers after work, and handle failed jobs so one capture does not leak resources.
  • Budget the browser environment. Puppeteer requires browser installation and runtime resources. puppeteer downloads a compatible Chrome during installation; puppeteer-core is for environments that manage their own browser. There is no single cost or timing figure that applies to every deployment.

Or skip the browser setup

If you want the screenshot delivered by an API, ScreenshotNeo takes a URL and returns an image or PDF. Its API can accept a target URL directly; see the ScreenshotNeo API documentation.

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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
  • Cookie banners and consent notices are accepted or removed, along with known newsletter popups and chat widgets, before the screenshot; each step can be turned off.
  • Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Responses include X-Page-Verdict and X-Billed headers.
  • An 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 free for 1,000 screenshots a month, with no card required.

FAQ

Does networkidle2 mean React hydration is complete?

No. It is a navigation/network condition, not a React lifecycle signal. Wait for the app state required by the screenshot.

Does hydrateRoot return a promise I can await?

React documents a root object with render and unmount methods, not a general hydration completion promise. Expose a readiness signal from your application or test harness.

Can I wait only for the component I need?

Yes. Wait for the target’s relevant state and use its element screenshot method. Remember that target presence does not establish that unrelated parts of the app are hydrated.

Should I use puppeteer or puppeteer-core?

Use puppeteer when its compatible Chrome download behavior suits the project. Use puppeteer-core when your environment supplies and manages the browser.