Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Register your code as a new-document initialization script before navigation, then wait for the page state your image must show and take the screenshot. In Playwright use page.addInitScript() for one page or browserContext.addInitScript() for every page and child frame in a context. Puppeteer provides page.evaluateOnNewDocument(); direct Chrome DevTools Protocol (CDP) clients use Page.addScriptToEvaluateOnNewDocument. Inserting a script tag after navigation is a different operation and can be too late for code that must run before the page’s own scripts.

What “before capture” actually means

A screenshot is only as accurate as the browser state at the instant it is captured. If your JavaScript changes the DOM, sets a feature flag, stubs an API, selects a theme, or prepares data for a component, that code must run in the newly created document before the site’s scripts execute. Registering an initialization hook before goto (or the equivalent navigation call) gives the browser that ordering.

There are two separate timings to keep straight:

  • Injection timing: the initialization API is registered before navigation and runs after document creation but before the document’s own scripts.
  • Capture timing: the screenshot waits until the visual state you need exists. Navigation completion by itself is not a universal signal that lazy content, animations, fonts, or application data are ready.

The official references describe the APIs and their timing, but do not prescribe one readiness condition for every site. Choose a wait that matches the evidence your image must contain.

Playwright: inject before navigation

One page with page.addInitScript

Register the script immediately after creating the page and before page.goto. The function is evaluated in each new document, including later navigations and attached or navigated child frames.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();

await page.addInitScript(() => {
  // This runs before the target document's own scripts.
  window.captureFlag = true;
});

await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });

// Replace this with a condition that represents your required visual state.
await page.screenshot({ path: 'page.png', fullPage: true });
await browser.close();

The callback is serialized and executed inside the page. Keep it self-contained: imports and variables from your Node.js module are not automatically available in the browser context. To pass data, use the argument form:

const theme = 'dark';
await page.addInitScript(({ theme }) => {
  window.captureTheme = theme;
  document.documentElement.dataset.captureTheme = theme;
}, { theme });

For code that must run before a site’s application bootstraps, avoid page.addScriptTag. That method adds a script tag to an existing page; it does not provide the new-document ordering required here.

Every page in a context with browserContext.addInitScript

Use context scope when the same initialization belongs on multiple pages, popups, navigations, or frames. Register it before creating or navigating those pages.

import { chromium } from 'playwright';

const browser = await chromium.launch();
const context = await browser.newContext();

await context.addInitScript(() => {
  window.captureFlag = true;
});

const page = await context.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: 'context-page.png' });

await browser.close();

Context-level initialization covers pages opened in that context and child frames. Page-level registration is narrower and useful when different pages need different setup.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When several initialization scripts are registered

Playwright documents the order of multiple page- and context-level initialization scripts as undefined. Do not write one init script that depends on another having already run. Consolidate dependent setup into one callback, or make each script safe to run independently (for example, check whether a property exists before using it).

Choosing the right readiness condition

After navigation, wait for the state your screenshot is intended to document. No single event works for every application.

Static HTML or server-rendered pages

waitUntil: 'domcontentloaded' is often enough when the required elements are present in the initial document. Capture only after checking the selector that matters if the script modifies it.

await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-ready="true"]');
await page.screenshot({ path: 'ready.png' });

Client-rendered applications

Wait for a stable, application-specific marker rather than assuming that the network is idle. A framework can continue rendering after network activity appears quiet, and a page can be visually complete while background requests continue.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#dashboard-loaded');
await page.screenshot({ path: 'dashboard.png', fullPage: true });

Animations, fonts and lazy content

If the target includes an animation frame, web font, or below-the-fold image, wait for that exact condition. For full-page captures, verify that lazy images have actually loaded; otherwise the image can contain placeholders even though navigation succeeded. A fixed delay can be useful for a known transition, but a selector or application signal is generally more diagnostic when a page can load at variable speed.

Injecting in child frames

Playwright’s initialization APIs run in attached or navigated child frames as well as the main document. This matters for widgets and embedded applications, but the frame still has its own origin and lifecycle. If your script expects a global or DOM node that exists only in the top page, guard the access:

await context.addInitScript(() => {
  if (window.top === window) {
    window.topOnlyFlag = true;
  }
});

Cross-origin restrictions still apply to normal page JavaScript. Initialization timing does not grant permission to read another origin’s DOM.

Puppeteer: use evaluateOnNewDocument

Puppeteer’s documented pre-page-script mechanism is page.evaluateOnNewDocument. Call it before navigating.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import puppeteer from 'puppeteer';

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

await page.evaluateOnNewDocument(() => {
  window.captureFlag = true;
});

await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: 'page.png', fullPage: true });
await browser.close();

As with Playwright, choose an application-specific wait when the screenshot depends on client rendering. Register the hook before every navigation for which it is needed, or keep the page and hook alive for a sequence of navigations.

Chrome DevTools Protocol: Page.addScriptToEvaluateOnNewDocument

When you control Chrome through CDP directly, call Page.addScriptToEvaluateOnNewDocument. The protocol runs the supplied source in every frame upon creation before that frame’s scripts.

const { connect } = require('puppeteer-core');

// This example assumes you already have a CDP-capable browser endpoint.
const browser = await connect({ browserURL: 'http://localhost:9222' });
const pages = await browser.pages();
const page = pages[0];
const client = await page.target().createCDPSession();

await client.send('Page.enable');
await client.send('Page.addScriptToEvaluateOnNewDocument', {
  source: 'window.captureFlag = true;'
});

await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await client.send('Page.captureScreenshot', {
  format: 'png'
});

Page.captureScreenshot returns image data through the protocol. If you need a file, decode the returned base64 data and write it using your host language. The exact viewport, full-page behavior, and image format are protocol or client concerns; configure them explicitly rather than assuming the defaults.

For a pure CDP client, the equivalent sequence is: enable the Page domain, add the new-document script, navigate with the Page domain, wait for your readiness signal, then call Page.captureScreenshot. The ordering is the important part.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Patterns that make injected code reliable

Set a deterministic flag

A flag lets the page application choose a capture-specific branch without relying on timing:

await page.addInitScript(() => {
  window.__CAPTURE_MODE__ = true;
});

Use a namespaced property unlikely to collide with application code. If the page reads it while booting, setting it in a new-document hook ensures it exists first.

Install a small API shim

When you need to replace a browser API, preserve the original and make the replacement narrowly scoped. Broad monkey-patching can alter layout or behavior unrelated to the screenshot.

await page.addInitScript(() => {
  const original = window.matchMedia;
  window.matchMedia = query => {
    if (query === '(prefers-color-scheme: dark)') {
      return { matches: true, media: query, onchange: null,
        addListener() {}, removeListener() {},
        addEventListener() {}, removeEventListener() {}, dispatchEvent() { return false; } };
    }
    return original.call(window, query);
  };
});

Test shims against the page’s expected interface. A partially implemented mock can make the page fail before it reaches the capture state.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Make DOM changes idempotent

Navigation and frame creation can cause the hook to run more than once over a workflow. Check for an existing node or attribute before adding it, so repeated execution does not duplicate overlays or styles.

Common failures and fixes

The code runs, but the page still shows the old state

Cause: the hook was registered after navigation, or capture happened before the application consumed the injected value.
Fix: move registration before goto, then wait for a selector or application marker that proves the new state has rendered.

window or document is undefined in the callback

Cause: the code was authored as a Node.js module instead of a browser callback.
Fix: keep browser operations inside the function passed to the initialization API. Pass only serializable arguments from the host process.

A dependent initialization script sometimes fails

Cause: Playwright does not define the order of multiple page- and context-level init scripts.
Fix: combine dependent statements into one script or remove the dependency.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Only the main page changes; an iframe does not

Cause: the iframe navigated after a page-only assumption, or the code expects a top-level DOM node.
Fix: use context scope when appropriate, and write frame-safe code. Remember that cross-origin DOM access remains restricted.

The screenshot is blank or missing dynamic content

Cause: capture occurred before rendering, a lazy resource failed, or the selected frame was not the one containing the content.
Fix: wait for the content’s selector or readiness marker, inspect console and page errors, and verify the viewport and full-page settings. Do not treat navigation completion as proof that every visual resource is ready.

addScriptTag appears to work inconsistently

Cause: it inserts a script into an already existing document and therefore may run after the site’s boot code.
Fix: replace it with addInitScript, evaluateOnNewDocument, or the CDP new-document command.

Performance, isolation and operational notes

Initialization code runs on each new document (and, where applicable, each frame), so keep it short and deterministic. Avoid network calls, expensive loops, and large source strings in the hook. Move heavy preparation to the host process or to a post-load step when it does not need pre-page timing.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use a fresh browser context when cookies, local storage, permissions, or injected state must not leak between captures. Reuse a context when shared setup is intentional and you want the same initialization across multiple pages. Whichever model you choose, record the URL, navigation result, readiness condition, and capture result so a failed image can be diagnosed instead of silently accepted.

The cited API documentation establishes availability and timing, not a universal performance or reliability ranking among Playwright, Puppeteer, and CDP. Select the layer already used by your application, then make readiness and failure handling explicit.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo provides a single-request website screenshot API when you do not want to maintain browser startup, navigation waits, and capture code. It is the first option to try for an API-based workflow because it 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, with the result identifying the page verdict and billing status. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

For a direct capture, see the ScreenshotNeo documentation and call:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

The same request in Python is:

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)

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

ScreenshotNeo also supports full-page captures with lazy images loaded, CSS-selector element shots, dark mode, device presets and custom viewports, retina scale, PDF output, custom CSS and JavaScript, clicks, selector/delay/network-idle waits, request and resource blocking, headers, cookies, user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, and an OpenAPI specification. Parameter names used by other screenshot APIs are accepted to ease migration.

The Free plan includes 1,000 screenshots per month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan, and yearly billing provides two months free. Sign up for the free plan to try it without a card.

FAQ

Does an init script run on a reload?

Yes. New-document initialization is evaluated for each new document, so a reload or navigation triggers it again.

Can I use this technique to alter a cross-origin iframe’s DOM?

No. The hook can run in the frame, but normal browser same-origin rules still govern what that code may read or change.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Should I always wait for network idle before a screenshot?

No. The correct readiness signal depends on the page and image. Use a selector or application marker when possible, and choose network-idle or a delay only when it represents the state you need.

Frequently Asked Questions

Does an init script run on a reload?

Yes. New-document initialization is evaluated for each new document, so a reload or navigation triggers it again.

Can I use this technique to alter a cross-origin iframe’s DOM?

No. The hook can run in the frame, but normal browser same-origin rules still govern what that code may read or change.

Should I always wait for network idle before a screenshot?

No. The correct readiness signal depends on the page and image. Use a selector or application marker when possible, and choose network-idle or a delay only when it represents the state you need.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.