html-to-image vs html2canvas: CORS, Fonts and When to Render Server-Side

Your toPng() call returns a blank image. Or a SecurityError about cssRules. Or a canvas with everything except the fonts — and it works fine in Chrome, but…

Your toPng() call returns a blank image. Or a SecurityError about cssRules. Or a canvas with everything except the fonts — and it works fine in Chrome, but your Safari users see nothing. These aren't random bugs. html-to-image and html2canvas fail in a small, well-documented set of ways, and every one of them traces back to how the libraries actually render.

By the end of this post you'll be able to reproduce and diagnose each failure mode with a runnable snippet, understand why the same bugs survive every fork, and decide, with a concrete checklist, when to keep patching the client and when to render server-side instead. You'll need a page with html-to-image and/or html2canvas installed, DevTools open, and ideally a second browser (Safari if you can get one).

How html-to-image actually renders (and why that design causes every bug below)

html-to-image clones your DOM into an SVG <foreignObject>, serializes that SVG to a dataURI, draws it onto an off-screen canvas, and reads the pixels back (npmjs.com). Anything the browser forbids in that pipeline (cross-origin resources, tainted canvases, oversized dataURIs) becomes a silent blank or a thrown error.

html2canvas takes the opposite approach: it re-implements rendering by parsing your CSS and drawing to canvas itself. That's why it has different failure modes (CSS variable resolution, canvas capture) but shares the CORS taint problem.

The key consequence: because rendering happens in the reader's browser, you inherit every CORS rule, font-loading quirk, and Safari bug that browser has. The library can't fix what the browser forbids.

Pitfall to avoid: assuming a fix that works in Chrome will work in Safari. The foreignObject pipeline behaves differently per browser, and there's no flag that normalizes it.

Failure mode 1: CORS-tainted canvases — the blank image with no error

If your DOM contains a canvas that is tainted (because it drew a cross-origin image), html-to-image's own docs state rendering will not succeed. Cross-origin <img> tags without CORS headers taint or render empty.

html2canvas's useCORS flag only applies to cross-origin URLs, so images that redirect from a local URL to a CDN can still taint the canvas, which is issue #3020 (github.com). And even allowTaint + useCORS can't help with same-origin-only contexts like a reCAPTCHA iframe, which throws Can only call open() on same-origin documents (issue #1544) (github.com).

This isn't a niche problem. monday.com's engineering team found CORS restrictions on externally loaded images affected every DOM-to-image library they tested (Capturing DOM as Image Is Harder Than You Think). Their workarounds were replacing failed images with a transparent 1×1 placeholder or inlining base64 data URLs before cloning.

Runnable diagnostic. Wrap your capture in a try/catch and probe every image's CORS state before capturing. Any failed fetch predicts a blank region:

async function diagnoseCORS(node) {
  const imgs = node.querySelectorAll('img');
  for (const img of imgs) {
    try {
      const res = await fetch(img.src, { mode: 'cors' });
      console.log('OK  ', img.src, res.status);
    } catch (e) {
      console.warn('FAIL', img.src, '→ this will be blank/tainted');
    }
  }
  try {
    const url = await htmlToImage.toPng(node);
    return url;
  } catch (e) {
    console.error('Capture threw:', e);
  }
}

Pitfall to avoid: setting allowTaint "to fix it." A tainted canvas can't be exported at all, so allowTaint guarantees you get nothing. It's the opposite of a fix.

Failure mode 2: font embedding and the cssRules SecurityError

To render web fonts, html-to-image parses your page's stylesheets to inline them — and reading CSSStyleSheet.cssRules on a cross-origin stylesheet (Google Fonts CDN, for example) throws SecurityError: CSSStyleSheet.cssRules getter: Not allowed to access cross-origin stylesheet (issue #301) (github.com).

All three major libraries (dom-to-image, html-to-image, html2canvas) cannot render images or fonts from external domains unless those domains serve CORS headers (dom-to-image vs html-to-image vs html2canvas). So the "fix" is usually self-hosting your fonts. That works, but understand what you've signed up for: you're now maintaining font assets just to make a client-side screenshot function work.

Runnable diagnostic. Iterate document.styleSheets and try reading cssRules in a try/catch. Every sheet that throws is one your capture will silently miss fonts or styles from:

function diagnoseStylesheets() {
  for (const sheet of document.styleSheets) {
    try {
      const n = sheet.cssRules.length;
      console.log('OK  ', sheet.href || 'inline', n, 'rules');
    } catch (e) {
      console.warn('FAIL', sheet.href, '→', e.name);
      // Fonts and rules from this sheet won't make it into the capture
    }
  }
}

Pitfall to avoid: self-hosting fonts fixes the error but not the pipeline. It's a legitimate trade-off for a small app, but it's maintenance you only take on because the capture happens client-side.

Failure mode 3: Safari — foreignObject bugs that persist in every fork

Issue #461 (opened Apr 12, 2024) reports blank images in Safari when capturing a div containing images, while Chrome works fine (github.com). Issue #488, opened Jan 9, 2025, summarizes Safari's state bluntly: SVG-type images unsupported, cross-origin images unsupported, and the first toBlob/toCanvas/toPng call always renders blank (github.com).

These aren't fixed by forking. They come from Safari's foreignObject implementation, which every library built on this pipeline inherits. A fork changes your code, not the browser.

Runnable diagnostic. Call toPng() twice in Safari and compare:

async function safariProbe(node) {
  const first = await htmlToImage.toPng(node);
  const second = await htmlToImage.toPng(node);
  console.log('first call length :', first.length);
  console.log('second call length:', second.length);
  // If the second is much longer and the first is tiny,
  // you're hitting the documented first-call blank bug.
}

If the second call works and the first is blank, you're hitting the documented first-call bug and you can warm it up. But your users on iOS still get broken captures, because the warm-up only helps when your code runs first — not when a user taps "download" on an iPhone.

Pitfall to avoid: shipping a "works on my machine (Chrome)" capture feature to a user base with iPhones. Test the capture path in Safari before you ship it, not after the bug reports.

If you're staring at a blank image right now and Safari is in your user base, this is the point where a server-side render sidesteps the whole class of problem — MarkupGo's HTML to Image API renders on the server, so there's no foreignObject pipeline to inherit Safari's bugs.

Failure mode 4: huge DOMs, silent empty areas, and other quiet failures

html-to-image's own docs state rendering fails on huge DOMs because the dataURI limit varies. A very large page can silently produce nothing.

Worse: by default, html-to-image renders empty areas for failed images. So a partially broken capture looks "successful" and you ship it.

Library-specific quirks to know: html-to-image renders canvas/WebGL elements as empty rectangles, while html2canvas captures their current pixel state (dom-to-image-more vs html-to-image vs html2canvas). And html2canvas often fails to resolve CSS variables inside nested components, defaulting to inherited or initial values.

Runnable diagnostic. Log the output dataURL length. A suspiciously short dataURL after a big capture means the dataURI limit or the silent empty-area behavior hit you:

async function validateCapture(node) {
  const dataUrl = await htmlToImage.toPng(node);
  // Rough sanity check: a real capture of a non-trivial node
  // is rarely under ~10KB. Tune the threshold for your content.
  if (dataUrl.length < 10_000) {
    console.error('Suspiciously small capture:', dataUrl.length, 'chars');
    // Don't ship it — treat as a failure
  }
  return dataUrl;
}

Pitfall to avoid: treating a non-throwing capture as a correct capture. Validate output size, or one day you'll ship blank invoices and find out from a customer.

The decision list: when client-side is fine, and when to render server-side

Client-side is fine when all of these are true:

  • Content is same-origin.
  • Images are CORS-enabled or inlined as base64.
  • Fonts are self-hosted.
  • Your users are on Chrome/Edge.
  • The DOM is small — a card, an OG-image preview, a chart snapshot.

Move server-side when any of these is true:

  • Cross-origin images or stylesheets you don't control.
  • Web fonts from a CDN.
  • Safari or iOS users.
  • Huge DOMs.
  • Canvas/WebGL content you need captured.
  • Captures running in background jobs, where there is no browser at all.

Also worth knowing: dom-to-image is effectively deprecated, with no meaningful GitHub updates since 2018 and known limitations flagged by the community. "Switch libraries" is not a real escape hatch — the failure modes are architectural.

The hybrid pattern that works: keep client-side capture for interactive previews, and call a server-side rendering API for anything user-facing, automated, or cross-origin. (One small html2canvas tip if you do stay client-side: you can exclude elements from rendering with the data-html2canvas-ignore attribute (html2canvas.hertzen.com).)

Rendering server-side: what the same HTML looks like

Every failure mode above exists because rendering happens in the reader's browser, subject to that browser's CORS rules, canvas taint, and SVG quirks. Server-side rendering eliminates the entire class: no canvas taint (the server fetches images directly), no cssRules SecurityError (the server reads its own stylesheets), no Safari foreignObject bugs (no foreignObject), and no dataURI limit (the DOM never gets serialized into one).

MarkupGo's HTML to Image API does exactly this — send HTML or a URL, get back a PNG, JPEG, or WebP with configurable width and height (markupgo.com). The NodeJS client keeps it to a few lines:

const markupgo = new MarkupGo(API_KEY);

const options = {
  properties: {
    format: "png", // "jpeg" | "png" | "webp"
    width: 1200,
    height: 630,
  }
};

const result = await markupgo.image.fromHtml(html, options).json();
// or .buffer() if you want the raw image bytes

If you want zero code at all, the Magic Template URL generates images from a plain URL with query parameters — build the template once in the dashboard, then render it with a URL like https://render.markupgo.com/template/{templateId}.png?title=Hello. One credit per unique URL, and the same URL can be reused without extra credits. That makes it a natural fit for OG images: put the Magic URL straight into your meta tags and every page gets its image without an API call.

The free trial is permanent and grants 100 credits per month, so you can point your failing capture at it before deciding between Lite ($29/mo, 2,000 credits), Plus ($49/mo, 10,000 credits), and Pro ($99/mo, 20,000 credits).

How to check it worked

Whichever path you take, verify with the same discipline:

  1. Run the CORS and stylesheet diagnostics from Failure modes 1 and 2. Every FAIL line is a region that will be blank.
  2. Capture in Chrome and Safari. If Safari differs, you now know why.
  3. Validate the output: check dataURL length (client-side) or image dimensions and file size (server-side). A non-throwing capture is not a correct capture.
  4. For the server-side path, render your actual production HTML once and compare it against the client-side capture side by side. If the server version has your fonts, your images, and your layout, you're done diagnosing.

What to do next

The decision rule to close on: if your capture is same-origin, static, and Chrome-only, ship html-to-image — it's a great tool. The moment any of these is true (cross-origin images or stylesheets, web fonts, Safari users, or a DOM big enough to hit the dataURI limit), stop patching the client and render server-side. Try MarkupGo's free trial (100 credits/month, no expiry) and replace your failing toPng() call with a few lines of Node; keep the client-side path for the cases it genuinely handles.

Frequently asked questions

Why does html-to-image produce a blank image with no error?

Because it renders by embedding your HTML in an SVG foreignObject, drawing that SVG to an off-screen canvas, and reading pixels back. If the DOM includes a tainted canvas, rendering will not succeed, and on huge DOMs the dataURI limit varies and the render can silently fail. Failed images render as empty areas by default, so the capture "succeeds" while looking blank.

Does html2canvas work with cross-origin images?

Only if the external domain serves CORS headers — all three major libraries (dom-to-image, html-to-image, html2canvas) share this limitation. Even then, useCORS is only applied to cross-origin URLs, so images that redirect from a local URL to a CDN can still taint the canvas. allowTaint makes things worse, since a tainted canvas can't be exported.

Does html-to-image work in Safari?

Partially, and unreliably. Documented Safari failures include blank images when capturing a div containing images, plus unsupported SVG-type images, unsupported cross-origin images, and a first toPng call that always renders blank. These come from Safari's foreignObject implementation, so forking the library doesn't fix them.

Is dom-to-image still maintained?

Effectively no. It has had no meaningful GitHub updates since 2018 and has known limitations. Switching to it isn't a fix for the failure modes described here, since they're architectural to the client-side rendering approach.

How much does it cost to render HTML to image with an API?

MarkupGo's free trial is permanent and grants 100 credits per month. Paid plans are Lite at $29/month (2,000 credits, 5 templates), Plus at $49/month (10,000 credits, 30 templates), and Pro at $99/month (20,000 credits, unlimited templates). With the Magic Template URL, one credit generates each unique URL, and reusing the same URL costs nothing extra.