html2canvas vs html-to-image: Tested Side by Side

html2canvas vs html-to-image on one test card: which CSS each renders, capture speed, bundle size, CORS failures, and when to move capture to a server.

By Simon Chiu · Updated

Disclosure: I build RenderScreenshot, which renders HTML to images on a server with real Chrome. That is a different approach from both libraries on this page. Everything below was measured with the libraries themselves; our API is only discussed in the last section.

Short answer: html-to-image matched Chrome's own pixels on our test card, ran 2.5 to 3 times faster after the first capture, and is about one ninth of the gzipped bundle size. html2canvas got four CSS features wrong, including the card's shadow, but it does one thing better: when an image fails to load, it leaves a gap and still gives you a picture, where html-to-image rejects the whole capture.

Both libraries run in the user's browser. If you need the same output for every user, or you need to capture pages you do not control, see the other screenshot tool comparisons and the server-side option at the end.

How the two libraries work

The difference in results comes from one design choice.

  • html2canvas walks the DOM, reads computed styles, and repaints every box, border, background and text run onto a <canvas> with its own JavaScript renderer. Anything its renderer does not know is skipped or approximated.
  • html-to-image clones the node, inlines computed styles, fonts and images as data URLs, wraps the clone in an SVG <foreignObject>, and lets the browser draw that SVG onto a canvas. The browser's own engine paints the CSS.

So html2canvas output is limited by its CSS coverage, and html-to-image output is limited by what can be serialized and embedded (images, fonts, cross-origin resources).

Test setup

Tested on 2026-10-15 with Node 22.18.0, [email protected] (Chrome for Testing 154, headless), [email protected] and [email protected], on macOS with an Apple M1 Pro.

  • Test card: a 640 px wide card built to cover features people use in share images and export buttons: a gradient background with rounded corners and a box shadow, a pill badge, gradient text with background-clip: text, a ::before quote mark, emoji and Japanese text, a CSS grid with a conic-gradient tile, a rotated tile and a dashed border with text-shadow, a blurred circle using filter: blur(), an inline SVG, and two images.
  • Reference: Puppeteer's element screenshot of the same card, which is what Chrome paints.
  • Method: load the page, inject the library, call it 7 times on the card. The first call is reported as "cold", the median of the other 6 as "warm". The whole run was repeated in 3 fresh browser sessions.
  • Calls used:
    • html2canvas(card, { useCORS: true, logging: false }), then canvas.toDataURL('image/png')
    • htmlToImage.toPng(card, { cacheBust: true })

Render results

Reference capture by Chrome: rounded card with soft shadow, purple gradient text, conic gradient tile, rotated blue tile, dashed tile, blurred circle top right, green SVG pill and two images
Reference: Chrome's element screenshot of the test card (Puppeteer elementHandle.screenshot()).
html-to-image output of the same card: visually identical to the Chrome reference, including gradient text, conic gradient and blur
html-to-image 1.11.13, toPng(): matches the reference on every feature we checked.
html2canvas output: gradient text replaced by a solid gradient bar, conic tile empty with white label only, sharp-edged circle instead of a blurred one, and hard gray bands where the soft shadow should be
html2canvas 1.4.1, default renderer: gradient text becomes a bar, the conic tile is blank, the blur is ignored, and the shadow turns into hard gray bands.
Feature html2canvas 1.4.1 html-to-image 1.11.13
Linear gradient background, border radius Yes Yes
Soft box-shadow on a rounded card No: hard gray bands and square corners Yes
background-clip: text (gradient text) No: solid gradient bar, text lost Yes
conic-gradient No: background missing Yes
filter: blur() No: drawn sharp Yes
transform: rotate() Yes Yes
Dashed border, text-shadow Yes Yes
::before pseudo-element content Yes Yes
Emoji and CJK text Yes Yes
Inline SVG Yes Yes
Same-origin image Yes Yes
Cross-origin image with CORS headers Yes (with useCORS: true) Yes

Speed and size

Measure html2canvas html-to-image
First capture (3 sessions) 141 to 415 ms 41 to 51 ms
Later captures, median (3 sessions) 77 to 91 ms 29 to 31 ms
PNG output for the card 201.5 KB 210.9 KB
Minified bundle, esbuild 0.25.0 204.8 KB 12.8 KB (toPng only)
Gzipped bundle 47.5 KB 5.2 KB
Latest release on npm (checked 2026-10-15) 1.4.1, January 2022 1.11.13, February 2025

The times are for one 640 px card on a fast laptop. On a mid-range phone, multiply by several times. The cold html2canvas number moved a lot between sessions, so treat it as a range.

The failure that will bite you: cross-origin images

We served the second image from another origin without an Access-Control-Allow-Origin header, which is common for third-party avatars and CDN images, and ran each library again.

Library and options Result
html2canvas, default Capture succeeded. The cross-origin image was silently missing.
html2canvas, useCORS: true Capture succeeded. CORS error in the console, image missing.
html-to-image, default Promise rejected with a DOM Event, not an Error. No image at all.
html-to-image, imagePlaceholder set Capture succeeded with the placeholder in place of the image.

Two practical rules follow.

  1. With html-to-image, always catch, and log something useful, because err.message is undefined on an Event:
import { toPng } from 'html-to-image';

const GRAY_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAMzMzP///yH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';

export async function captureCard(node) {
  try {
    return await toPng(node, {
      pixelRatio: 2,                 // sharper output on any screen
      imagePlaceholder: GRAY_PIXEL,  // keep going if one image cannot be fetched
      cacheBust: true,
    });
  } catch (err) {
    const reason = err instanceof Error ? err.message : `resource failed to load (${err?.type ?? 'unknown'})`;
    throw new Error(`Card capture failed: ${reason}`);
  }
}
  1. With html2canvas, a "successful" capture can still be wrong. If images matter, check that every <img> in the node is same-origin or served with CORS headers before you trust the output.

Does foreignObjectRendering close the gap?

html2canvas has a foreignObjectRendering: true option that uses the same SVG trick as html-to-image. We tried it on the same card.

html2canvas with foreignObjectRendering: CSS effects now render correctly, but the card is shifted up and left, the badge is cut off, and both images show as broken image icons with alt text
html2canvas with foreignObjectRendering: true: correct CSS, wrong position, no images.

The CSS problems went away, but the card was offset by the page padding, the top line was clipped, and both images appeared as broken-image icons because this mode does not inline them. Warm captures took about 80 ms. In our test it was not a drop-in fix.

Minimal usage for each

html2canvas:

import html2canvas from 'html2canvas';

const node = document.querySelector('#card');
const canvas = await html2canvas(node, {
  scale: 2,           // device pixel ratio for the output
  useCORS: true,      // try CORS for cross-origin images
  backgroundColor: null, // transparent instead of the default white
  logging: false,
});
const dataUrl = canvas.toDataURL('image/png');

html-to-image:

import { toPng, toBlob } from 'html-to-image';

const node = document.querySelector('#card');
const dataUrl = await toPng(node, { pixelRatio: 2 });
// toBlob avoids a large base64 string when you upload the result
const blob = await toBlob(node, { pixelRatio: 2 });

Both run only in a browser. In Node you need a headless browser around them, which is what the test harness below does.

Decision table

Situation Choose
Modern CSS (gradients, filters, clipped text, shadows) must look right html-to-image
Bundle size matters (a widget, a landing page) html-to-image
The node contains images you do not control and a partial picture is better than an error html2canvas, or html-to-image with imagePlaceholder
You already use html2canvas and your layouts are simple boxes and text Keep html2canvas
Output must be identical for every user, browser and device Neither: render on a server
You need to capture another site, not your own DOM Neither: both only see the current document

A note on scope: we tested in Chromium only. html-to-image relies on the browser's SVG foreignObject support, which differs between engines, so run your own cards in Safari and Firefox before you ship. Our html2canvas troubleshooting guide covers more failure cases, and the JavaScript HTML-to-image tutorial goes deeper into the html-to-image options.

Reproduce the test

Install [email protected] [email protected] [email protected], put your page at corpus/features.html with the target node as #card, serve the project folder (any static server that sends Access-Control-Allow-Origin: * works), and run this with BASE_URL set to that server:

// Compare html2canvas and html-to-image inside headless Chrome.
import puppeteer from 'puppeteer';
import { mkdir, writeFile } from 'node:fs/promises';

const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:8080';
const RUNS = 7;
const libs = {
  html2canvas: {
    script: '/node_modules/html2canvas/dist/html2canvas.min.js',
    run: `async () => (await html2canvas(document.getElementById('card'), { useCORS: true, logging: false })).toDataURL('image/png')`,
  },
  'html-to-image': {
    script: '/node_modules/html-to-image/dist/html-to-image.js',
    run: `async () => htmlToImage.toPng(document.getElementById('card'), { cacheBust: true })`,
  },
};
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];

await mkdir('out', { recursive: true });
const browser = await puppeteer.launch();
try {
  for (const [name, lib] of Object.entries(libs)) {
    const page = await browser.newPage();
    await page.setViewport({ width: 800, height: 700 });
    await page.goto(`${BASE}/corpus/features.html`, { waitUntil: 'networkidle0' });
    await page.addScriptTag({ url: lib.script });
    const times = [];
    let dataUrl;
    for (let i = 0; i < RUNS; i++) {
      const t0 = performance.now();
      dataUrl = await page.evaluate(`(${lib.run})()`);
      times.push(performance.now() - t0);
    }
    const png = Buffer.from(dataUrl.split(',')[1], 'base64');
    await writeFile(`out/${name}.png`, png);
    console.log(name, `cold ${times[0].toFixed(0)} ms`, `warm ${median(times.slice(1)).toFixed(0)} ms`, `${png.length} bytes`);
    await page.close();
  }
} finally {
  await browser.close();
}

When the browser is the problem: render on a server

Both libraries depend on the visitor's browser: its fonts, its engine, its memory, and its CORS rules. When that is not acceptable, render the same HTML on a server with real Chrome. With RenderScreenshot you can send the markup directly (see HTML content input):

curl -o card.png \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html": "<div style=\"width:640px;padding:28px;font-family:sans-serif\"><h2>Weekly report</h2></div>", "viewport": {"width": 700, "height": 400}, "output": {"format": "png"}}' \
  https://api.renderscreenshot.com/v1/screenshot

Or capture one element of a live page with the Node SDK:

import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);
const png = await client.take(TakeOptions.url('https://example.com').element('body p').scale(2));
await writeFile('element.png', png);

Images on the page load like they do for any visitor, so there is no canvas CORS step, and every user gets the same pixels. If you escape user input before you put it into the html string, this is also safe for user-generated cards. Try it with a free API key, or test markup in the HTML to image tool.

Last tested: 2026-10-15 in Chromium only. Re-test planned on the next release of either library.

Frequently asked questions

Why does html2canvas not render my gradient text?
html2canvas re-implements CSS painting in JavaScript and does not support background-clip: text. In our test it painted the gradient as a solid bar and the text disappeared. html-to-image rendered it correctly because the browser paints it.
Why does html-to-image fail with an Event object instead of an Error?
When an image cannot be fetched, for example a cross-origin image without CORS headers, the promise rejects with the failed load event. Catch it, and either serve the image with CORS headers or pass imagePlaceholder.
Is html-to-image faster than html2canvas?
On our test card in headless Chrome, html-to-image took about 30 ms per capture after the first one, and html2canvas took about 80 ms. The first capture was slower for both.
Does html2canvas foreignObjectRendering fix the CSS problems?
It fixed the missing CSS in our test, but the output was shifted by the page padding and both images were missing. Test it on your own layout before you rely on it.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.