---
title: "How to Wait for a Page to Finish Rendering Before Taking a Screenshot"
description: "Wait for data, fonts, images and charts before taking a screenshot with Puppeteer, Playwright or HTML/CSS to Image."
published: "2026-09-01"
author: "Jeffrey Needles"
canonical: "https://htmlcsstoimage.com/blog/wait-for-page-before-screenshot"
---


A browser's `load` event is a pretty low bar. It can fire while an application is still fetching account data, drawing a chart, swapping fonts or filling in an empty table. Take the screenshot at that point and you may get a spinner, a blank chart or half a report.

Chromium cannot tell when the application has finished its work. It only knows what is happening at the browser level.

Add a ready marker to the page after the important work finishes. Puppeteer or Playwright can wait for that marker before taking the screenshot.

> [!NOTE]
> This tutorial focuses on Chromium screenshots made with Puppeteer, PuppeteerSharp, Playwright or HTML/CSS to Image. The same readiness pattern also works for [HTML-to-PDF rendering](/blog/optimize-html-for-pdf-printing).

## What Does "Ready" Actually Mean?

There are several points where browser automation can stop waiting, and each one means something different.

| Signal | What it tells you | What may still be unfinished |
|:-------|:------------------|:-----------------------------|
| `DOMContentLoaded` | The HTML was parsed | Images, fonts, API calls and client-side rendering |
| `load` | The document and its initial resources loaded | Later API calls, charts and other JavaScript work |
| Network idle | Network activity has gone quiet | Animations, rendering work and anything not tied to a request |
| A selector | A particular element or state exists | Anything outside the condition you chose |
| An application-ready marker | The work included in your marker logic is complete | Work that was not included before setting the marker |
| A fixed delay | A certain amount of time passed | Anything that takes longer than the delay |

`load` is often enough for a mostly static page. Network idle can work when the page makes a small, predictable batch of requests. It is less useful with polling, event streams and analytics connections that stay open. Playwright's own [`page.goto()` documentation](https://playwright.dev/docs/api/class-page#page-goto-option-wait-until){target=_blank} discourages using `networkidle` as a general readiness test.

A selector lets you wait for something specific, such as a populated report, a visible chart or the end of a loading state. If you can edit the page, a dedicated ready marker can cover all of those checks in one place.

## Step 1: Add a Ready Marker to the Page

Set the marker after everything needed for the screenshot has rendered. For a dashboard, that might look like this:

```js
async function renderDashboard() {
  const data = await loadDashboardData();

  renderSummary(data.summary);
  await renderCharts(data.charts);
  renderRecentActivity(data.activity);

  await document.fonts.ready;
  document.documentElement.dataset.screenshotReady = "true";
}
```

That last line produces this attribute on the root `<html>` element:

```html
<html data-screenshot-ready="true">
```

There is nothing special about the attribute name. A class, element ID or JavaScript variable can do the same job. A data attribute works well because it is easy to find in DevTools and does not affect the visible page.

The important part is where you set it. An API request finishing does not necessarily mean the chart has finished drawing. If the chart library has a completion callback or returns a promise, wait for it before setting the attribute.

## Step 2: Wait for the Marker, Then Take the Screenshot

All four examples use the same sequence: navigate, wait for the marker and capture the full page. The 30-second timeout keeps a broken render from tying up the worker forever.

::: tabs
@tab Puppeteer (JS)

```js
import puppeteer from "puppeteer";

const browser = await puppeteer.launch({ headless: true });

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

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

  await page.waitForSelector('[data-screenshot-ready="true"]', {
    timeout: 30_000,
  });

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

@tab PuppeteerSharp (.NET)

```csharp
using PuppeteerSharp;

await using var browser = await Puppeteer.LaunchAsync(
    new LaunchOptions { Headless = true });
await using var page = await browser.NewPageAsync();

await page.GoToAsync(
    "https://example.com/dashboard",
    new NavigationOptions
    {
        WaitUntil = new[] { WaitUntilNavigation.DOMContentLoaded }
    });

await page.WaitForSelectorAsync(
    "[data-screenshot-ready=\"true\"]",
    new WaitForSelectorOptions { Timeout = 30_000 });

await page.ScreenshotAsync(
    "dashboard.png",
    new ScreenshotOptions { FullPage = true });
```

@tab Playwright (JS)

```js
import { chromium } from "playwright";

const browser = await chromium.launch();

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

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

  await page
    .locator('[data-screenshot-ready="true"]')
    .waitFor({ state: "attached", timeout: 30_000 });

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

@tab Playwright (.NET)

```csharp
using Microsoft.Playwright;

using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();

await page.GotoAsync(
    "https://example.com/dashboard",
    new PageGotoOptions
    {
        WaitUntil = WaitUntilState.DOMContentLoaded
    });

await page
    .Locator("[data-screenshot-ready=\"true\"]")
    .WaitForAsync(new LocatorWaitForOptions
    {
        State = WaitForSelectorState.Attached,
        Timeout = 30_000
    });

await page.ScreenshotAsync(new PageScreenshotOptions
{
    Path = "dashboard.png",
    FullPage = true
});
```
:::

Puppeteer's [`waitForSelector()`](https://pptr.dev/api/puppeteer.page.waitforselector){target=_blank} and Playwright's [`locator.waitFor()`](https://playwright.dev/docs/api/class-locator#locator-wait-for){target=_blank} return as soon as the selector matches. PuppeteerSharp exposes the corresponding methods on its [`IPage` interface](https://www.puppeteersharp.com/api/PuppeteerSharp.IPage.html){target=_blank}.

> [!TIP]
> **A timeout is an upper limit, not a delay.** A 30-second timeout does not make every screenshot wait 30 seconds. If the marker appears after 200 milliseconds, the screenshot can continue immediately. The timeout only decides how long to wait before treating the render as a failure.

If you cannot change the page's code to add a ready marker, wait for an element that appears only after the useful content has loaded. For a report table, the first populated row is a better signal:

```js
// Wait for a specific row to load
await page.waitForSelector(".report-table tbody tr");

// This only confirms that the table itself is present
await page.waitForSelector(".report-table");
```

The table shell may be present from the first render while its rows are still loading.

## Step 3: Account for Fonts, Images and Charts

Before you set the marker, make sure the fonts, images and charts are ready too. All three can keep changing the page after the rest of the content has loaded.

### Fonts

A fallback font can wrap a heading onto two lines where the real font uses one. That changes the position of everything below it, so the screenshot should wait until the expected font is in use.

For most Chromium pages, start with [`document.fonts.ready`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready){target=_blank}:

```js
await document.fonts.ready;
```

It resolves after the fonts currently used by the page have finished loading and the related layout work is done. It does not force every font declared in CSS to load. For example, a bold face that is not used anywhere yet may still be unloaded.

If the screenshot requires a particular web font, weight or style, request it directly with [`document.fonts.load()`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/load){target=_blank}:

```js
async function waitForScreenshotFonts() {
  const requiredFonts = [
    ['400 16px "Inter"', "Account statement 0123456789"],
    ['700 24px "Inter"', "Account statement 0123456789"],
  ];

  for (const [font, sampleText] of requiredFonts) {
    const loadedFaces = await document.fonts.load(font, sampleText);

    if (loadedFaces.length === 0) {
      throw new Error(`No web font matched: ${font}`);
    }
  }

  await document.fonts.ready;
}

await waitForScreenshotFonts();
document.documentElement.dataset.screenshotReady = "true";
```

The font string uses the same syntax as the CSS `font` shorthand, including the weight, size and family. The sample text also matters when a font is split into files with different `unicode-range` values, so include characters that are representative of the screenshot. In this example, a missing `@font-face` match or a failed font request stops the render instead of silently using a fallback font.

This code runs inside the page. Put it in the application before the ready marker, or run the same function through `page.evaluate()` in Puppeteer or Playwright.

`document.fonts.check()` sounds like it would verify that a named font exists, but that is not quite what it does. It checks whether the text can be rendered without starting another font load. A nonexistent font can still return `true` because the browser can use a fallback. The [MDN notes for `check()`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/check){target=_blank} call out that behavior.

#### Measuring Text as a Fallback

Older font-loading helpers often detect a font by measuring the same text with two different fallback fonts. Once the requested font is active, both measurements should use that font and end up with the same width.

```js
async function waitForFontByMeasurement(fontFamily, timeoutMs = 5000) {
  const sampleText = "BESbswy 0123456789";
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");

  function measure(font) {
    context.font = font;
    return context.measureText(sampleText).width;
  }

  const fallbacks = ["monospace", "serif"];
  const fallbackWidths = fallbacks.map((fallback) =>
    measure(`48px ${fallback}`)
  );
  const deadline = performance.now() + timeoutMs;

  while (performance.now() < deadline) {
    const widths = fallbacks.map((fallback) =>
      measure(`48px "${fontFamily}", ${fallback}`)
    );

    const fallbacksNowMatch = Math.abs(widths[0] - widths[1]) < 0.01;
    const measuredWidthChanged = widths.some(
      (width, index) => Math.abs(width - fallbackWidths[index]) >= 0.01
    );

    if (fallbacksNowMatch && measuredWidthChanged) {
      return;
    }

    await new Promise((resolve) => setTimeout(resolve, 50));
  }

  throw new Error(`Font did not load: ${fontFamily}`);
}

await waitForFontByMeasurement("Inter");
```

This is a fallback for pages where you cannot use the CSS Font Loading API. It can be fooled by fonts with very similar metrics, font synthesis and unusual fallback behavior. For current Chromium screenshot workers, `document.fonts.load()` followed by `document.fonts.ready` is the clearer option.

### Images

An `<img>` can report `complete` even when it failed to load. If a particular image is required, decode it and check `naturalWidth` too:

```js
const logo = document.querySelector(".company-logo");

await logo.decode();

if (logo.naturalWidth === 0) {
  throw new Error("Company logo did not load");
}
```

`decode()` waits until the image is ready to be drawn. If the logo is required, fail the screenshot job when it does not load.

Lazy-loaded images need a little more attention. A full-page screenshot captures the entire document, but it does not necessarily scroll through the page first. Images with `loading="lazy"` may never get close enough to the viewport to start loading.

If you control the page, disable lazy loading in screenshot mode or change those images to `loading="eager"`. Otherwise, scroll through the document once, wait for the newly requested images to finish, return to the top and capture. Use `decode()` or the image load events after scrolling rather than adding another fixed delay.

### Charts and canvas elements

Waiting for a `<canvas>` selector only proves that the canvas exists. Most chart libraries add the canvas before they fetch data, calculate the layout or draw anything, so it may still be blank when the selector appears.

Use the chart library's render-complete event, animation callback or promise, then set the ready marker. If the chart animates into place, turn that animation off in screenshot mode when possible. Otherwise, screenshots may be taken at different points in the animation even though the underlying data is the same.

For a chart without a completion hook, set the marker after your own chart-rendering function resolves. If that function updates the DOM synchronously, wait for the next animation frame before setting the marker so the browser can apply the layout changes:

```js
await renderCharts(data);
await new Promise(requestAnimationFrame);

document.documentElement.dataset.screenshotReady = "true";
```

## Why a Fixed Sleep Is Usually the Wrong Wait

This is tempting:

```js
await new Promise((resolve) => setTimeout(resolve, 3000));
```

This fails whenever the page needs more than three seconds.

Three seconds is wasteful when the page is ready in 300 milliseconds and useless when it needs four seconds. Playwright labels [`page.waitForTimeout()`](https://playwright.dev/docs/api/class-page#page-wait-for-timeout){target=_blank} as discouraged for production code for the same reason.

That unused wait time adds up. An unnecessary three-second delay across 10,000 screenshots uses more than eight hours of browser time. If you run your own workers, that means lower throughput or more workers. A ready marker lets the render continue as soon as the page is done.

A fixed delay can still make sense for a known 200 ms CSS transition that you cannot disable. It should not be used to guess how long an API request or render will take.

## Waiting with HTML/CSS to Image

With HTML/CSS to Image, the equivalent switch is [`render_when_ready`](https://docs.htmlcsstoimage.com/parameters/render_when_ready/){target=_blank}:

```json
{
  "url": "https://example.com/dashboard",
  "render_when_ready": true
}
```

For HTML passed directly to the API, call `ScreenshotReady()` after the page is finished:

```html
<script>
  async function start() {
    const data = await loadDashboardData();
    await renderDashboard(data);
    await document.fonts.ready;

    ScreenshotReady();
  }

  start();
</script>
```

For a URL screenshot, the helper is not injected into your page. Add an element with the expected ID yourself:

```js
const ready = document.createElement("div");
ready.id = "HCTIReadyNow";
ready.hidden = true;
document.body.appendChild(ready);
```

The renderer waits for that element before taking the screenshot. If a short unconditional pause is all you have to work with, [`ms_delay`](https://docs.htmlcsstoimage.com/parameters/ms_delay/){target=_blank} is available. Use the ready element when the page can provide one.

## Common Problems

### The Screenshot Still Shows a Loading State

Something is changing after the marker is set. A second state update from a chart, framework effect or image callback is the usual culprit. Move the marker after that update.

### Network Idle Never Finishes

Look for polling, event streams or analytics requests. If the page is designed to keep talking to the network, it will never be idle. Wait for page state instead.

### The Selector Exists, but the Content Is Empty

The selector probably points at a container that was there all along. Wait for a populated row, a success state or an attribute added after rendering.

### Text Moves Between Otherwise Identical Screenshots

Wait for `document.fonts.ready`, then confirm that the screenshot worker can access the same font files as a normal browser session. A blocked font request leaves you with the fallback face every time.

### A Full-Page Screenshot Misses Images Near the Bottom

Those images are probably lazy-loaded. Turn that behavior off in screenshot mode or scroll far enough to trigger the images before capture.

## Frequently Asked Questions

#### Should I Use `load` or `networkidle` Before a Screenshot?

For a mostly static document, `load` is usually enough. Network idle can work when the page makes a finite, predictable set of requests. For pages with API data, polling or charts, use a selector or ready marker instead.

#### Is `waitForTimeout()` Ever Okay?

Yes. It is reasonable for a short animation with a known duration. It is not reliable for data or rendering because page speed changes from run to run.

#### What Selector Should I Wait For?

Pick the selector that proves the content you need is there. If you need table rows, do not wait for the empty table wrapper. If you own the page, a dedicated `data-screenshot-ready="true"` attribute removes the guesswork.

#### Can I Use the Same Ready Signal for PDF Generation?

Yes. Puppeteer and Playwright can wait for the same marker before calling `page.pdf()`. The print layout still needs its own CSS, page sizing and break rules, which are covered in [How to Optimize HTML for PDF Printing](/blog/optimize-html-for-pdf-printing).

## Wait for Something Specific

`DOMContentLoaded`, `load` and network idle describe browser activity. They do not confirm that a report has rows or that a chart has finished drawing.

If you can edit the page, add a marker after the data, charts, fonts and required images are ready. If you cannot edit it, wait for a selector that proves the content you need is present. The screenshot can continue as soon as the condition is met.

HTML/CSS to Image supports the same approach with `render_when_ready` if you do not want to maintain the browser worker. You can [try it with your own HTML or URL](https://htmlcsstoimage.com/){target=_blank}.
