ScreenshotNeo

BlogHow-to

Why html2canvas Cannot Capture Images and How to Fix It

Missing images in html2canvas usually mean a cross-origin or CORS problem. Learn the fixes, proxy pattern, export limits, and reliable debugging steps.

By the ScreenshotNeo team1 October 202610 min read

Short answer: html2canvas does not take a native screenshot. It rebuilds a representation from the DOM, and browsers prevent a canvas from safely reading pixels loaded from another origin unless the image server explicitly allows it with CORS. When that check fails, html2canvas skips the image by default. Fix it by keeping the image same-origin, enabling useCORS: true when the remote server sends the right header, or fetching the image through a controlled same-origin proxy. Do not use allowTaint: true when you need to export the canvas.

The project describes the result as a DOM-based reconstruction rather than an actual screenshot. That distinction explains why an image, iframe, font, video, or browser-rendered effect can look correct to a visitor but be absent from the canvas. See the official FAQ, configuration reference, and getting-started guide.

What is actually failing?

There are two separate questions:

  1. Can the browser load the image? A 404, redirect, authentication failure, CSP rule, or timeout can stop the request.
  2. Can the image be drawn into an exportable canvas? If the final image response is cross-origin without permission, drawing it taints the canvas. html2canvas checks for this and skips the image when allowTaint is false, which is the default.

The official FAQ states: “Drawing images that reside outside of the origin of the current page taints the canvas, making it unreadable.” A missing image therefore usually indicates an origin or CORS problem, not an incorrect CSS selector.

How the browser decides whether an image is safe

An origin is the combination of scheme, host, and port. These are different origins:

https://app.example.com/photo.png
https://cdn.example.com/photo.png   // different host
http://app.example.com/photo.png    // different scheme
https://app.example.com:8443/a.png  // different port

Redirects matter. An <img> that starts on your domain but ultimately redirects to a CDN or storage host is cross-origin at the point the browser receives the bytes. The Network panel’s final request URL is the one to evaluate.

For a cross-origin image to remain readable by canvas, the image response must include an appropriate Access-Control-Allow-Origin value for the requesting page (or a permitted wildcard where your security policy allows it), and the request must be made in CORS mode. html2canvas cannot add that response header for you.

Fix decision tree

Situation Recommended fix Canvas export
The image is genuinely same-origin Use the final same-origin URL; investigate redirects, status codes, and authentication. Works when the image loads.
You control the remote image server Return an appropriate Access-Control-Allow-Origin header and set useCORS: true. Works when headers and request mode match.
You do not control the remote server Fetch it through a controlled proxy on your page’s origin and configure proxy. Works if the proxy returns usable image bytes/data.
You only set allowTaint: true Do not treat this as the export fix. The canvas may become unreadable by toDataURL() or toBlob().

Fix 1: keep images same-origin

The simplest solution is to serve the image from the same scheme, host, and port as the page. Store uploaded assets behind your application domain, or configure your CDN with a same-origin custom domain. Verify that the URL does not redirect elsewhere.

<img id="hero" src="/assets/hero.webp" alt="Product illustration">

<script type="module">
  import html2canvas from "html2canvas";

  const element = document.querySelector("#hero");
  const canvas = await html2canvas(element);
  canvas.toBlob((blob) => {
    if (!blob) throw new Error("Canvas export failed");
    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = "capture.png";
    link.click();
    URL.revokeObjectURL(link.href);
  }, "image/png");
</script>

Same-origin does not bypass ordinary loading errors. Check authentication cookies, signed URL expiry, CSP, mixed-content blocking, and the response status.

Fix 2: use CORS correctly

useCORS: true tells html2canvas to request images in CORS mode. It only works when the image server agrees. The response must contain a matching Access-Control-Allow-Origin header; adding the option alone cannot make an uncooperative server readable.

Browser capture

<div id="invoice">
  <img src="https://cdn.example.com/invoice-logo.png" alt="Logo">
  <h1>Invoice</h1>
</div>

<script type="module">
  import html2canvas from "html2canvas";

  const node = document.querySelector("#invoice");
  const canvas = await html2canvas(node, {
    useCORS: true,
    imageTimeout: 15000
  });

  const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
  if (!blob) throw new Error("The canvas could not be exported");

  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "invoice.png";
  a.click();
  URL.revokeObjectURL(url);
</script>

Server response example

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Content-Type: image/png

Use the exact page origin when credentials or a restricted allowlist are involved. A wildcard origin cannot be combined with credentialed requests. Configure the header at the CDN, object-storage bucket, image service, or reverse proxy that serves the final image URL.

Fix 3: proxy the image through your origin

When the image server cannot send CORS headers, a server-side proxy can fetch the image and expose it from your own origin. The html2canvas getting-started guide demonstrates a proxy that accepts a ?url= parameter, retrieves the resource, and returns it as a data URI.

A production proxy must be controlled. Validate allowed hosts, block private IP ranges and cloud metadata addresses, enforce response-size and timeout limits, preserve the content type, and avoid forwarding sensitive cookies. Do not assume a public proxy is safe or available.

Minimal Express proxy

import express from "express";

const app = express();
const allowedHosts = new Set(["cdn.example.com", "images.example.org"]);

app.get("/image-proxy", async (req, res) => {
  try {
    const target = new URL(String(req.query.url || ""));
    if (target.protocol !== "https:" || !allowedHosts.has(target.hostname)) {
      return res.status(400).send("Host is not allowed");
    }

    const upstream = await fetch(target, { signal: AbortSignal.timeout(10000) });
    if (!upstream.ok) return res.status(upstream.status).end();

    const type = upstream.headers.get("content-type") || "";
    if (!type.startsWith("image/")) return res.status(415).send("Not an image");

    const bytes = Buffer.from(await upstream.arrayBuffer());
    if (bytes.length > 10 * 1024 * 1024) return res.status(413).send("Image too large");

    res.set("Content-Type", type);
    res.set("Cache-Control", "private, max-age=300");
    res.send(bytes);
  } catch {
    res.status(400).send("Could not fetch image");
  }
});

app.listen(3000);
<img src="/image-proxy?url=https%3A%2F%2Fcdn.example.com%2Fphoto.png" alt="Remote photo">

In html2canvas, set proxy to the proxy endpoint when using the library’s proxy flow. A proxy adds a network hop and therefore latency; cache stable assets and keep the proxy close to the browser or application where appropriate.

Wait for images before capturing

Starting capture immediately after inserting an image can produce a blank or partial result even when CORS is correct. Wait for every image to finish, and give lazy-loaded images a chance to enter the viewport.

function waitForImages(root) {
  const images = [...root.querySelectorAll("img")];
  return Promise.all(images.map((img) => {
    if (img.complete) {
      return img.naturalWidth > 0
        ? Promise.resolve()
        : Promise.reject(new Error(`Image failed: ${img.src}`));
    }
    return new Promise((resolve, reject) => {
      img.addEventListener("load", resolve, { once: true });
      img.addEventListener("error", () => reject(new Error(`Image failed: ${img.src}`)), { once: true });
    });
  }));
}

const target = document.querySelector("#capture");
await waitForImages(target);
const canvas = await html2canvas(target, {
  useCORS: true,
  imageTimeout: 15000
});

The documented default for imageTimeout is 15,000 milliseconds. Set it to 0 to disable the timeout only when an unbounded wait is acceptable; otherwise keep a finite timeout and report which asset failed.

Why allowTaint: true is usually the wrong fix

allowTaint: true permits drawing an image that can taint the canvas. It does not grant permission to read those pixels later. Calls such as canvas.toDataURL() and canvas.toBlob() can throw a security error or fail because the canvas is no longer origin-clean. Use this setting only when you do not need canvas readback or export, and understand that it does not solve the underlying browser policy.

Canvas size and blank or truncated output

Browsers impose maximum canvas dimensions. A very tall page can therefore produce a blank or truncated capture even after image loading is fixed. The html2canvas FAQ and configuration reference recommend setting windowWidth and windowHeight to the element’s scroll dimensions when capturing large content.

const target = document.querySelector("#long-page");
const canvas = await html2canvas(target, {
  useCORS: true,
  windowWidth: target.scrollWidth,
  windowHeight: target.scrollHeight
});

For extremely long documents, capture sections and stitch them in a separate image pipeline, or generate a PDF with a browser automation tool. html2canvas is a DOM approximation and is not a replacement for a native browser screenshot.

Exclude problematic nodes

If one widget is impossible to load or is irrelevant to the capture, exclude it deliberately. Add data-html2canvas-ignore to an element, or use the ignoreElements predicate.

<div data-html2canvas-ignore="true" class="live-chat-widget">...</div>

const canvas = await html2canvas(document.body, {
  ignoreElements: (element) => element.matches(".advert, .third-party-widget")
});

Images html2canvas cannot render

  • Cross-origin iframes: browser security prevents access to their contentDocument. Same-origin iframes can be rendered recursively.
  • Flash and Java applets: plugin content is not rendered.
  • Browser-only effects: Because the library reconstructs the DOM, unsupported CSS and browser-rendered content can differ from what the user sees.
  • Node.js execution: The library depends on window and document; the official guide says it is not suitable for Node.js without a browser environment.

Debugging checklist

  1. Open DevTools Network and find the final image request after redirects.
  2. Confirm the response status, content type, and whether the body is actually an image.
  3. Compare the image origin with the page origin, including scheme and port.
  4. Check the response for Access-Control-Allow-Origin.
  5. Set useCORS: true before capture when the server supports CORS.
  6. Wait for img.complete and a positive naturalWidth.
  7. Check the browser console for canvas security errors, CSP violations, mixed content, or blocked requests.
  8. Use a controlled same-origin proxy when you cannot change the image server.
  9. Set windowWidth and windowHeight for large captures.
  10. Exclude a known-bad widget with data-html2canvas-ignore or ignoreElements.

Common errors and fixes

Symptom Likely cause Fix
Image area is empty, no exception Cross-origin image and no usable CORS response. Same-origin hosting, useCORS: true plus server headers, or a controlled proxy.
useCORS: true changes nothing The server omits the header, sends the wrong origin, or redirects to another host. Inspect the final response and configure CORS there.
SecurityError: canvas has been tainted An image was drawn without permission. Make every image CORS-safe; do not rely on allowTaint: true for export.
Capture stops after 15 seconds imageTimeout reached its documented 15,000 ms default. Fix the slow/failed asset, increase the timeout, or set 0 with care.
Very tall capture is blank or clipped Canvas dimension limit. Set viewport dimensions, capture in sections, or use a browser screenshot/PDF workflow.
Only an embedded third-party app is missing Cross-origin iframe security. Capture the iframe’s own page with permission, or use a server-side browser capture.
Works locally but fails in production Different host, HTTPS scheme, CDN headers, cookies, or CSP. Inspect production requests and final origins rather than assuming local behavior.

Performance, reliability, and cost considerations

  • Reduce work: capture the smallest element, exclude advertisements and live widgets, and avoid unnecessarily large viewport dimensions.
  • Control waiting: wait for required images, but retain finite timeouts so one stalled CDN does not hang every capture.
  • Proxy overhead: a proxy adds a fetch and possibly a conversion to data URI. Cache immutable assets and enforce size limits.
  • Security: an unrestricted ?url= proxy can become a server-side request forgery and data exfiltration path. Use an allowlist and block internal destinations.
  • Exportability: only origin-clean canvases can be reliably read back. Plan the CORS or proxy path before building download, upload, or PDF features.
  • Native screenshots: if pixel fidelity, cross-origin pages, iframes, or server-side automation matter more than a DOM approximation, use a browser screenshot service.

Or skip the browser setup

For a native page capture, ScreenshotNeo accepts one GET request and returns PNG, JPEG, WebP, or PDF. The service handles the browser session for you:

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

See the ScreenshotNeo API documentation for request options. Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers identify the page verdict and billing result. An MCP server lets AI agents take screenshots, inspect pages, and capture PDFs. The free plan includes 1,000 screenshots each month with no card, and paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and start with the monthly free allowance.

FAQ

Does adding crossorigin="anonymous" fix every image?

No. It requests CORS mode, but the image response still needs a suitable Access-Control-Allow-Origin header. Without that response permission, the image remains unusable for an exportable canvas.

Why does the image display normally but disappear in the canvas?

Displaying an image is allowed; reading its pixels from a canvas is more restricted. A cross-origin response without CORS permission can be painted by the page but must be skipped or taint the canvas during export.

Can I use html2canvas in a backend job?

Not by itself. It relies on browser globals such as window and document. Use it inside a real browser context, or use a browser screenshot service for server-side capture.

Should I make every image a data URL?

Data URLs avoid a later cross-origin fetch, but converting every asset increases memory use and application complexity. Same-origin hosting or a controlled proxy is usually easier to operate.

Why are same-origin iframes different?

A page can access a same-origin iframe’s document and reconstruct it. Browser security blocks access to the document of a cross-origin iframe, so html2canvas cannot render that content.

Will a proxy make the capture pixel-perfect?

No. A proxy solves image origin and loading problems. html2canvas still reconstructs the page from DOM and CSS, so unsupported browser-rendered features can differ from the native view.