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

Wait for the page to become idle, then explicitly decode every image currently in the document before calling page.screenshot(). Puppeteer’s page.evaluate() waits for a promise returned by the browser-side function, so image readiness can be enforced in the page before capture.

await page.evaluate(async () => {
  await document.fonts.ready;

  await Promise.all(
    Array.from(document.images, async (image) => {
      await image.decode();
      if (!image.naturalWidth) {
        throw new Error(`Broken image: ${image.src}`);
      }
    }),
  );
});

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

This checks decoded, usable <img> elements that exist when the check runs. Lazy-loaded content, images inserted later, and CSS background images need additional handling.

Why network idle alone can still produce missing images

page.waitForNetworkIdle() describes network activity; it does not guarantee that every image has been decoded and painted. A request can finish while the browser is still decoding the bitmap, and a page can add more images after the network becomes quiet. A screenshot taken at that point may therefore contain empty image boxes or partially rendered content.

Use navigation or network-idle waits as a first stage, not as the final image-readiness test. The reliable sequence is:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Navigate to the page and wait for the state your application requires.
  2. Trigger lazy content and other dynamic sections that should appear in the capture.
  3. Wait for fonts and image decoding.
  4. Validate failures and enforce a finite timeout.
  5. Capture the viewport, an element, or the full page.

A complete Puppeteer implementation

Basic decode check for images already in the DOM

import puppeteer from 'puppeteer';

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

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

await page.evaluate(async () => {
  await document.fonts.ready;

  await Promise.all(
    Array.from(document.images, async (image) => {
      await image.decode();
      if (!image.naturalWidth) {
        throw new Error(`Broken image: ${image.src}`);
      }
    }),
  );
});

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

image.decode() resolves after an image is decoded for rendering. Checking naturalWidth prevents a failed resource from being treated as ready. A decode rejection is also a failure signal. In this example any failed image aborts the job, which is appropriate when a complete visual is required.

Use a bounded wait and return diagnostics

Never let an automation worker wait forever for a resource that will never succeed. The following helper applies a timeout and reports the URLs that failed or remained incomplete.

async function waitForImages(page, timeoutMs = 30_000) {
  return page.evaluate(async (timeout) => {
    const images = Array.from(document.images);
    const work = Promise.all(images.map(async (image) => {
      try {
        await image.decode();
      } catch (error) {
        return {
          src: image.currentSrc || image.src,
          status: 'decode-error',
          error: String(error),
        };
      }

      return {
        src: image.currentSrc || image.src,
        status: image.naturalWidth ? 'ready' : 'broken',
      };
    }));

    let timer;
    const deadline = new Promise((resolve) => {
      timer = setTimeout(() => resolve({ timedOut: true }), timeout);
    });

    const result = await Promise.race([
      work.then((items) => ({ timedOut: false, items })),
      deadline,
    ]);
    clearTimeout(timer);
    return result;
  }, timeoutMs);
}

const result = await waitForImages(page);
if (result.timedOut) {
  throw new Error('Timed out while waiting for images');
}

const failed = result.items.filter((item) => item.status !== 'ready');
if (failed.length) {
  throw new Error(`Images not ready: ${JSON.stringify(failed)}`);
}

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

Decide your policy explicitly. For invoices, reports, visual regression tests, and archival captures, aborting with a URL list is usually safer than silently producing an incomplete file. For a best-effort thumbnail service, you may continue while logging the missing resources and exposing that status to callers.

Handling lazy-loaded and dynamically inserted images

The decode loop only sees images present when it executes. Modern pages often use loading="lazy", intersection observers, client-side hydration, or infinite scrolling. Scroll through the page (or otherwise trigger the component) before collecting images.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
await page.evaluate(async () => {
  const step = Math.max(window.innerHeight, 400);
  for (let y = 0; y < document.body.scrollHeight; y += step) {
    window.scrollTo(0, y);
    await new Promise((resolve) => requestAnimationFrame(() => resolve()));
  }
  window.scrollTo(0, 0);
});

await page.waitForNetworkIdle({ idleTime: 500, timeout: 30_000 });
await waitForImages(page, 30_000);
await page.screenshot({ path: 'full-page.png', fullPage: true });

Scrolling once may not be enough for an infinite feed. Set a content limit, repeat until the expected selector exists, or stop when the document height no longer grows. If JavaScript inserts images after your readiness check, run the check again immediately before capture.

Wait for a known selector when the page has a clear milestone

await page.waitForSelector('.article-gallery img', { visible: true, timeout: 30_000 });
await page.waitForFunction(
  () => [...document.querySelectorAll('.article-gallery img')]
    .every((img) => img.complete),
  { timeout: 30_000 },
);
await waitForImages(page, 30_000);

complete indicates that loading has finished, but it does not by itself prove that the image is usable. Keep the decode and naturalWidth checks for the final decision.

CSS backgrounds, posters, and other visual assets

document.images does not include CSS background-image URLs, SVG backgrounds, or video poster frames. If those assets matter, identify the elements and wait for their resources separately. A practical approach is to inspect computed styles, extract URL values, and preload them with browser Image objects.

await page.evaluate(async () => {
  const urls = new Set();
  for (const element of document.querySelectorAll('*')) {
    const style = getComputedStyle(element);
    const match = style.backgroundImage.match(/url(["']?(.*?)["']?)/);
    if (match) urls.add(match[1]);
  }

  await Promise.all([...urls].map((src) => new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = resolve;
    image.onerror = () => reject(new Error(`Background failed: ${src}`));
    image.src = src;
  })));
});

This is an application-specific check: gradients, data URLs, multiple backgrounds, responsive images, and cross-origin policies require more parsing. If your screenshot contract includes these assets, define which selectors and resource types are mandatory rather than assuming that an <img> check covers the page.

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

Viewport, element, and full-page captures

Image readiness is independent of screenshot extent. A viewport screenshot captures what is visible; an element screenshot targets one node; fullPage: true captures the page’s full scrollable extent and defaults to false. Choose the extent only after preparing the content.

await page.screenshot({ path: 'viewport.png' });
await page.locator('.hero').screenshot({ path: 'hero.png' });
await page.screenshot({ path: 'document.png', fullPage: true });

For full-page work, set the viewport before navigation, trigger all lazy sections, and run the final readiness check after scrolling. A full-page option does not itself wait for images.

Failure modes and fixes

The screenshot is taken before images appear

Cause: capture follows navigation or a selector wait without decoding. Fix: call the page-side image.decode() loop immediately before screenshot().

decode() rejects

Cause: the response is invalid, blocked, canceled, or otherwise unusable. Fix: record currentSrc, inspect the browser console and network response, then retry or fail according to your completeness policy. Do not hide the rejection.

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

naturalWidth is zero

Cause: the image is broken or has not produced a usable bitmap. Fix: verify the final URL, authentication, redirects, content type, and server response. If the image is intentionally empty, exclude that selector from your required set.

Lazy images remain blank

Cause: they were not inserted or requested when the check ran. Fix: scroll or interact to trigger them, wait for the resulting network activity, then rerun the image check.

A timeout hangs the worker

Cause: an image request or decode never completes. Fix: use a finite timeout, cancel or close the page on failure, and return diagnostics. The correct timeout depends on your page and network; there is no universal value.

Fonts or layout shift change the result

Cause: text reflows while fonts load. Fix: await document.fonts.ready before image checks and capture. If client-side rendering continues, also wait for the application’s own ready marker.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Best Value
The SQL Programming Language: .
  • Used Book in Good Condition
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Performance and reliability practices

  • Use one browser page per job and close it in a finally block.
  • Set navigation, selector, network-idle, and image-readiness timeouts separately so failures identify their stage.
  • Capture the image URL, status, and elapsed time in logs; this makes intermittent CDN or authentication failures diagnosable.
  • Do not add an arbitrary sleep as your only solution. A sleep can be too short on a slow run and wasteful on a fast one.
  • For retries, reload only after recording the first failure and cap the number of attempts.
  • Use stable test pages or application readiness markers for visual regression jobs.
  • Limit infinite-scroll expansion to a known amount of content to avoid unbounded memory and capture times.

Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF output, while its capture flow accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot. Each step can be turned off.

Only clean shots are billed: bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing. The response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

For API options such as full-page capture with lazy images loaded, CSS-selector element capture, custom JavaScript, waits, blocking rules, device presets, retina scale, cookies, headers, geolocation, caching, signed links, asynchronous jobs, bulk capture, and PDF controls, see the ScreenshotNeo 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)
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}`);

The Free plan includes 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free, and every feature is available on every plan. Create a free ScreenshotNeo account to try it.

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

FAQ

Does fullPage: true wait for every image?

No. It changes the capture extent only. Prepare and validate images separately.

Should a single broken image fail the entire job?

For complete documents, yes; for thumbnails, you may continue if the missing URL and status are reported.

Can I wait for only images inside one component?

Yes. Query that component’s image elements and apply the same decode and width checks to that subset.

Is a fixed delay ever sufficient?

It can mask a timing issue but cannot prove readiness. Prefer event-based checks with a finite deadline.

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.