ScreenshotNeo

BlogHow-to

How to Wait for Images to Load Before Capturing with html2canvas

Wait for every target image to load and decode before calling html2canvas. Handle lazy loading, failures, CORS, timeouts, and reliable exports.

By the ScreenshotNeo team1 October 20269 min read

To wait for images before capturing with html2canvas, find every <img> inside the capture target, wait until each image is usable and decoded, then await html2canvas itself. Do not treat img.complete as proof of success: it can also be true for a broken image or an image with no source.

Use a readiness check before html2canvas

This helper waits for decoded images, validates successful loads, and falls back to load/error events when decode() is unavailable.

async function waitForImages(root) {
  const images = [...root.querySelectorAll('img')];

  await Promise.all(images.map(async (img) => {
    // complete can also mean broken or empty, so verify naturalWidth.
    if (img.complete && img.naturalWidth > 0) {
      if (typeof img.decode === 'function') {
        try {
          await img.decode();
        } catch (error) {
          throw new Error(`Image decode failed: ${img.currentSrc || img.src}`);
        }
      }
      return;
    }

    if (typeof img.decode === 'function') {
      try {
        await img.decode();
      } catch (error) {
        throw new Error(`Image decode failed: ${img.currentSrc || img.src}`);
      }
      if (img.naturalWidth === 0) {
        throw new Error(`Image is not usable: ${img.currentSrc || img.src}`);
      }
      return;
    }

    await new Promise((resolve, reject) => {
      img.addEventListener('load', resolve, { once: true });
      img.addEventListener('error', () => {
        reject(new Error(`Image failed: ${img.currentSrc || img.src}`));
      }, { once: true });
    });

    if (img.naturalWidth === 0) {
      throw new Error(`Image is not usable: ${img.currentSrc || img.src}`);
    }
  }));
}

async function capture(element) {
  await waitForImages(element);
  return await html2canvas(element, {
    imageTimeout: 15000,
    useCORS: true
  });
}

const canvas = await capture(document.querySelector('#invoice'));
const png = canvas.toDataURL('image/png');

decode() resolves when the browser has decoded an image for use and rejects when decoding fails. The naturalWidth > 0 check distinguishes a usable image from a broken or empty one. The html2canvas call also returns a promise, so await it before exporting the canvas. See the MDN documentation for complete and the decode() method.

A complete browser example

The following page waits for all images inside #capture, captures the element, and downloads a PNG. Replace the image URLs and selector with your own target.

<!doctype html>
<html lang='en'>
<head>
  <meta charset='utf-8'>
  <script src='https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js'></script>
</head>
<body>
  <section id='capture'>
    <h1>Product report</h1>
    <img src='/images/hero.jpg' alt='Hero'>
    <img src='https://images.example.com/chart.png' alt='Chart'>
  </section>
  <button id='save'>Save PNG</button>

  <script>
    async function waitForImages(root) {
      const images = [...root.querySelectorAll('img')];
      await Promise.all(images.map(async (img) => {
        if (img.complete && img.naturalWidth > 0) {
          if (img.decode) await img.decode().catch(() => {
            throw new Error(`Could not decode ${img.currentSrc || img.src}`);
          });
          return;
        }
        if (img.decode) {
          await img.decode();
          if (img.naturalWidth === 0) throw new Error(`Invalid image ${img.src}`);
          return;
        }
        await new Promise((resolve, reject) => {
          img.addEventListener('load', resolve, { once: true });
          img.addEventListener('error', () => reject(new Error(`Failed ${img.src}`)), { once: true });
        });
      }));
    }

    document.querySelector('#save').addEventListener('click', async () => {
      const target = document.querySelector('#capture');
      try {
        await waitForImages(target);
        const canvas = await html2canvas(target, {
          imageTimeout: 15000,
          useCORS: true,
          backgroundColor: '#ffffff'
        });
        const link = document.createElement('a');
        link.download = 'report.png';
        link.href = canvas.toDataURL('image/png');
        link.click();
      } catch (error) {
        console.error('Capture failed', error);
      }
    });
  </script>
</body>
</html>

Why img.complete alone is unsafe

complete becomes true when the image has finished loading, but also when loading failed or when the element has no source. Pair it with naturalWidth > 0, then call decode() where available. This prevents a capture from starting with a failed image that only appears to be ready.

Lazy-loaded images

An image with loading='lazy' may not request its resource until it is near the viewport. Waiting cannot resolve an image that has not started loading. Before the readiness check, make required images eligible to load:

function triggerLazyImages(root) {
  for (const img of root.querySelectorAll('img[loading="lazy"]')) {
    img.loading = 'eager';
  }
}

const target = document.querySelector('#capture');
triggerLazyImages(target);
await waitForImages(target);
const canvas = await html2canvas(target);

For content rendered only after scrolling or intersection events, scroll the relevant container or use the same application code that normally reveals the content. Run the check again immediately before capture if the DOM or image sources can change.

Choosing a failure policy

Decide what a failed image means for your application instead of silently accepting an incomplete screenshot.

Policy Use when Implementation
Reject Every image is required, such as an invoice or evidence capture. Let decode() or the error event reject and show an error.
Omit and continue A missing thumbnail is acceptable. Log the URL, remove or hide the failed image, then capture.
Replace A consistent layout matters more than the original asset. Set a fallback src, wait for it, then capture.
async function waitAllowingFailures(root, onFailure) {
  const images = [...root.querySelectorAll('img')];
  await Promise.all(images.map(async (img) => {
    try {
      if (img.complete && img.naturalWidth > 0) {
        if (img.decode) await img.decode();
        return;
      }
      if (img.decode) {
        await img.decode();
      } else {
        await new Promise((resolve, reject) => {
          img.addEventListener('load', resolve, { once: true });
          img.addEventListener('error', reject, { once: true });
        });
      }
      if (img.naturalWidth === 0) throw new Error('zero natural width');
    } catch (error) {
      onFailure(img, error);
    }
  }));
}

await waitAllowingFailures(document.querySelector('#capture'), (img, error) => {
  console.warn('Omitting image', img.currentSrc || img.src, error);
  img.remove();
});

html2canvas options that affect image readiness

The html2canvas configuration reference documents these relevant settings:

  • imageTimeout: maximum time html2canvas waits for an image; the documented default is 15000 milliseconds. Set it to 0 to disable that timeout. It is a timeout, not a guarantee that an image will load successfully.
  • useCORS: asks the browser to request images with CORS. The image server must send a permitting CORS header.
  • proxy: routes image loading through a proxy when direct CORS access is unavailable.
  • onclone: lets you modify the cloned document before rendering, useful for replacing failed assets or changing lazy-loading attributes.
  • allowTaint: does not make an origin-tainted canvas readable. It is not a fix for export or toDataURL() failures.

Check the documentation for the exact version installed because configuration can vary between releases.

Cross-origin images and tainted canvases

An image can load in the page and still be unusable for canvas export. If it comes from another origin, the image response must allow the browser’s CORS request. Use useCORS: true only when the remote server is configured for it, or configure html2canvas’s documented proxy option. If the canvas is tainted, calls such as toDataURL() or getImageData() throw a security error. Waiting longer does not change that security policy. The html2canvas FAQ explains this limitation.

Images created after the first render

Frameworks and image components may replace src, add images, or render content after your first wait. Perform all state changes first, then query the target and wait again:

await renderReport({ showCharts: true });
const target = document.querySelector('#capture');
triggerLazyImages(target);
await waitForImages(target);
const canvas = await html2canvas(target);

For animated images, waiting for decode only guarantees the first usable decoded frame. Freeze animations or replace animated assets when deterministic output matters.

Troubleshooting

Images are missing even though the page looks loaded

Cause: the capture started before the target images decoded, or the images are lazy-loaded outside the viewport.
Fix: run waitForImages(target) immediately before html2canvas, make lazy images eager or trigger their intersection behavior, and verify naturalWidth > 0.

complete is true but the image is broken

Cause: complete also covers failed and empty images.
Fix: require naturalWidth > 0 and handle decode() rejection or the error event.

The wait never finishes

Cause: an image request is stalled, an event listener was attached after an earlier event, or a custom image component never assigns a usable source.
Fix: check currentSrc, network errors, and the element’s src/srcset. Test complete before attaching listeners and add an application-level deadline if the capture must return.

decode() rejects

Cause: the resource is corrupt, unavailable, or no longer matches the element’s source.
Fix: apply your chosen reject, omit, or replacement policy; do not convert the rejection into a false success.

The canvas is tainted or export throws a security error

Cause: a cross-origin image was not permitted by CORS.
Fix: configure the image server’s CORS response, use useCORS with that server, or use a configured proxy. allowTaint does not make a tainted canvas exportable.

The screenshot has the image but CSS looks wrong

Cause: html2canvas reconstructs a representation from DOM and supported CSS; it does not capture the browser’s native pixels. Unsupported CSS, external resources, and canvas size limits can affect fidelity independently of image readiness.
Fix: isolate whether the issue is loading or rendering, simplify unsupported styles, and consult the project’s supported-features documentation.

html2canvas times out

Cause: its imageTimeout elapsed while an image was still loading.
Fix: identify the slow or failed URL, fix the resource, choose an appropriate timeout, or set imageTimeout: 0 only when an unlimited wait is acceptable. Keep your own readiness and failure policy.

Performance and reliability guidance

  • Limit the wait to images inside the capture target instead of scanning the entire document.
  • Use Promise.all so independent images wait concurrently.
  • Cache or preload assets when the same images are captured repeatedly.
  • Do not add arbitrary sleeps as the primary readiness mechanism; network and decode times vary.
  • Record failed URLs and elapsed time so slow origins can be fixed.
  • For large pages, capture a smaller element, reduce image dimensions, or split the work to avoid browser canvas limits.
  • Run readiness after all DOM mutations, lazy-load triggers, and style changes.

Or skip the browser setup

If you need a server-side screenshot instead of maintaining browser readiness code, ScreenshotNeo accepts one GET request and returns PNG, JPEG, WebP, or PDF. Its capture flow accepts cookie and consent banners as a visitor, then removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and whether the shot was billed. An MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for all options.

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

ScreenshotNeo includes full-page and element capture, lazy-image loading, custom CSS and JavaScript, waits for selectors, delays or network idle, request blocking, headers, cookies, user agents, authentication, device presets, retina scale, caching, signed links, asynchronous jobs, webhooks, bulk capture, usage data, and PDF options. The Free plan includes 1,000 shots each month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.

FAQ

Is window.onload enough?

No. It does not guarantee that dynamically inserted, lazy-loaded, or later-replaced images in your target are decoded.

Should I use a fixed delay?

A delay can hide timing bugs but cannot prove readiness. Query the target images and await their decode or load/error result.

Does html2canvas capture images outside my element?

Only content included in the cloned capture tree is relevant. Wait for every image that the target actually contains, including nested elements.

Can waiting fix unsupported CSS?

No. Waiting solves image readiness. html2canvas still has rendering and browser canvas limits.

What should happen when one image fails?

Choose explicitly: reject for required content, omit optional content, or replace it with a fallback before capturing.