node-html-to-image vs satori: Same Card, Measured

node-html-to-image vs satori on one OG card template: render time, batch speed, file size, CSS coverage and the defaults that broke our first renders in each.

By Simon Chiu · Updated

node-html-to-image and satori both turn a template into a PNG in Node.js, and both are popular for Open Graph cards. They share almost nothing underneath.

  • node-html-to-image renders real HTML and CSS in headless Chrome through Puppeteer. Anything Chrome can draw, it can draw. You pay Chrome's start-up time and memory.
  • satori (from Vercel) converts a React-style element tree to SVG without a browser, using its own layout engine. You convert the SVG to PNG with a library such as @resvg/resvg-js. It is fast and small, and it supports a subset of CSS.

In our test, satori rendered a 1200x630 card in about 163 ms warm. node-html-to-image took about 2.3 seconds for one card and about 170 ms per card in a tuned batch. Choose satori for cards built from simple layouts at request time. Choose node-html-to-image when the design needs full browser CSS or you already have HTML templates.

Disclosure: I build RenderScreenshot, a hosted API that renders HTML to images in Chromium. Both libraries are open source and not ours. Every number here comes from our run on 2026-10-15.

This page is part of our comparison hub. If you are comparing satori with raw Puppeteer instead of this wrapper, read satori vs Puppeteer for OG images.

Verdict

You want... node-html-to-image satori
Lowest latency per card No: Chrome start per call Yes: about 163 ms warm, PNG included
Run in an edge or serverless function with no Chrome No Yes (it powers @vercel/og)
Full CSS: any selector, @font-face, web fonts by URL Yes Partial: inline styles and <style> rules, fonts passed as buffers
Templates as HTML files with {{variables}} Yes, Handlebars No: JSX or element objects
Emoji without setup Yes, from system fonts No: needs an emoji image loader
Many cards in one call Yes: pass an array of contexts Loop in your own code
Smallest dependency No: Puppeteer downloads Chrome Yes
Browser version you control Pinned: 5.0.0 depends on Puppeteer 23.2.2 No browser

The test

We built one 1200x630 card twice: as HTML and CSS for node-html-to-image, and as a satori element tree with the same styles. Both used the same Inter WOFF files from @fontsource/inter, so fonts were not a variable.

Method: 2026-10-15, macOS on an Apple M1 Pro, Node 22.18.0. node-html-to-image 5.0.0 with the Puppeteer it pins, 23.2.2, which launched Chrome 128.0.6613.119. satori 0.57.2 with @resvg/resvg-js 2.6.2. Single-card times are the median of five calls for node-html-to-image and of twenty for satori. Batch times are one call with 20 titles.

Measure node-html-to-image satori + resvg
One card, first call 2,540 ms 297 ms (1,242 ms in an earlier run)
One card, median 2,261 ms 163 ms (SVG 3 ms, PNG 160 ms)
20 cards, default settings 21,701 ms (1,085 ms per card) 20 x 163 ms, about 3.3 s in a loop
20 cards, tuned (waitUntil: 'load', viewport set) 3,396 ms (170 ms per card) Same
PNG size 352 KB (tuned) 263 KB
SVG size n/a 47 KB

Two results surprised us:

  1. node-html-to-image's default wait cost 6x. Its default waitUntil is networkidle0, which waits for 500 ms with no network traffic, per image. Our template loads nothing from the network, so that was pure waiting. Switching to 'load' cut the batch from 21.7 s to 3.4 s.
  2. satori's PNG step is the slow part. Layout to SVG took 3 ms. Rasterizing 1200x630 with resvg took 160 ms. If you can serve the SVG (some platforms accept it, most social networks do not), satori is very fast.

The defaults that broke our first renders

Our first attempt with each library produced a wrong card. Both bugs came from defaults, not from the libraries being broken, and both are easy to miss in review.

Three OG cards stacked. Top, node-html-to-image with default settings: the purple gradient restarts two thirds of the way across, leaving a visible vertical seam. Middle, node-html-to-image with a 1200x630 viewport: a smooth gradient. Bottom, satori: a smooth gradient and the same text layout.
Top: node-html-to-image 5.0.0, default options. Middle: with puppeteerArgs: { defaultViewport: { width: 1200, height: 630 } }. Bottom: satori 0.57.2 with boxSizing: 'border-box', PNG by resvg. 2026-10-15.

node-html-to-image: the 800px seam. The library screenshots the body element, and Puppeteer's default viewport is 800x600. Our body was 1200px wide with a gradient background. CSS paints a body background relative to the viewport, so the gradient ended at 800px and started again. The fix is one option: set the viewport to the card size.

satori: the missing footer. Our first satori card had no footer row. Satori follows the browser default box-sizing: content-box, so width: 100% plus 72px of padding made the root 1,344px wide and pushed the footer below the 630px canvas. The HTML version had box-sizing: border-box in its CSS and we forgot to carry it over. Add boxSizing: 'border-box' to the root.

Code for both

node-html-to-image, with both fixes applied:

// node-html-to-image 5: Handlebars template in, PNG buffers out.
import { writeFile } from 'node:fs/promises';
import nodeHtmlToImage from 'node-html-to-image';

const html = `<html><head><style>
  body { margin: 0; width: 1200px; height: 630px; box-sizing: border-box; padding: 72px;
         display: flex; flex-direction: column; justify-content: center;
         background: linear-gradient(135deg, #0f172a, #7c3aed); color: #f8fafc;
         font-family: Inter, system-ui, sans-serif; }
  h1 { font-size: 64px; margin: 0; }
</style></head><body><h1>{{title}}</h1><p>{{site}}</p></body></html>`;

const posts = [
  { title: 'Puppeteer vs Playwright', site: 'acme.dev' },
  { title: 'Fonts on Linux servers', site: 'acme.dev' },
];

const images = await nodeHtmlToImage({
  html,
  content: posts,                 // an array renders one image per item
  type: 'png',
  waitUntil: 'load',              // default is networkidle0, which was 6x slower in our test
  puppeteerArgs: { defaultViewport: { width: 1200, height: 630 } },  // default viewport is 800x600
});
await Promise.all(images.map((buf, i) => writeFile(`card-${i + 1}.png`, buf)));
console.log(`saved ${images.length} cards`);

Handlebars escapes {{title}} for you. Use {{{title}}} (three braces) only for HTML you trust.

One more thing we found while measuring: node-html-to-image 5.0.0 depends on Puppeteer 23.2.2 exactly, so npm install gave us a second, older Puppeteer next to our own 24.x and a Chrome 128 download. The library accepts a puppeteer option, which lets you pass the Puppeteer module you already maintain so you get its Chrome and its security fixes.

satori, without JSX so it runs in plain Node:

// satori 0.57 + resvg: element tree to SVG to PNG, no browser.
import { readFile, writeFile } from 'node:fs/promises';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

const font = await readFile('node_modules/@fontsource/inter/files/inter-latin-700-normal.woff');
const el = (type, style, children) => ({ type, props: { style, children } });

const card = (title, site) => el('div', {
  width: '100%', height: '100%', boxSizing: 'border-box', padding: 72,
  display: 'flex', flexDirection: 'column', justifyContent: 'center',
  backgroundImage: 'linear-gradient(135deg, #0f172a, #7c3aed)', color: '#f8fafc', fontFamily: 'Inter',
}, [el('div', { fontSize: 64 }, title), el('div', { fontSize: 28, marginTop: 24 }, site)]);

const svg = await satori(card('Puppeteer vs Playwright', 'acme.dev'), {
  width: 1200,
  height: 630,
  fonts: [{ name: 'Inter', data: font, weight: 700, style: 'normal' }],
});
const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
await writeFile('card-satori.png', png);
console.log(`saved card-satori.png (${png.length} bytes, SVG ${svg.length} chars)`);

satori places text as text, not markup, so a title with < or & is safe without escaping. The element objects are what JSX compiles to. In a React or Next.js project you would write <div style={{...}}> instead.

CSS coverage we checked

We rendered one small tile per feature through satori 0.57.2 and looked at each result. Chrome (node-html-to-image) handled all of them.

Feature satori 0.57.2 result
Flexbox row Rendered
display: grid with 1fr 1fr Rendered as a grid
linear-gradient background Rendered
box-shadow Rendered
transform: rotate() Rendered
CSS variable (--c then var(--c)) Rendered
calc(100% - 40px) width Rendered
filter: blur() Rendered
lineClamp: 1 Rendered with an ellipsis
position: absolute Rendered
Emoji (🚀) with no emoji loader Empty box

Satori's own README lists the limits that our tiles did not hit: no @font-face (fonts come in as buffers), no external stylesheets, interaction pseudo-classes such as :hover never match, display: table throws, and percentages inside calc() only work for sizes and spacing. Invalid inline styles throw and fail the whole render unless you pass onStyleError.

Many blog posts say satori supports only flexbox. That was true of early versions. Check the README of the version you install.

Operations

node-html-to-image satori
What you deploy Puppeteer and a Chrome binary (hundreds of MB) Two npm packages, plus your font files
Memory A Chrome process per call, or per cluster Your Node process only
Concurrency Built on puppeteer-cluster; tune puppeteerArgs and cluster options As many renders as your CPU allows
Runs on edge runtimes No Yes, with the WASM build of resvg
Linux fonts and emoji Install fonts in the image Load font and emoji assets yourself

When each one wins

satori wins for OG images generated on request, at the edge, from a small set of layouts: a title, an author, a logo, a background. Most Next.js sites fit this. The Next.js OG image guide shows that setup.

node-html-to-image wins when the card is real HTML: it reuses your site's CSS, uses fonts by URL, shows emoji from system fonts, or includes an SVG chart. It is also the simpler path when designers hand you HTML.

Neither fits when you want browser-accurate rendering without Chrome in your deployment. That is what a rendering API is for. With RenderScreenshot you send the same HTML as the node-html-to-image template, emoji included, and get the image back rendered in Chromium and cached on a CDN. HTML input is described under content input.

// RenderScreenshot: the satori card's HTML, rendered in Chromium, returned as WebP.
import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) =>
  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);

const client = new Client(process.env.RS_API_KEY);
const card = (title, site) => `<div style="width:1200px;height:630px;box-sizing:border-box;padding:72px;
  display:flex;flex-direction:column;justify-content:center;color:#f8fafc;font-family:system-ui;
  background:linear-gradient(135deg,#0f172a,#7c3aed)"><div style="font:700 64px system-ui">${escapeHtml(title)}</div>
  <div style="font-size:28px;margin-top:24px">${escapeHtml(site)} 🚀</div></div>`;

const image = await client.take(
  TakeOptions.html(card('Puppeteer vs Playwright', 'acme.dev')).preset('og_card').format('webp')
);
await writeFile('card-rs.webp', image);
console.log('saved card-rs.webp');

We tested this against a local mock of our API, not the live service. A network call will not beat satori's 163 ms on your own server, so keep satori if its CSS subset covers your design. Get a free API key if you want Chrome output without running Chrome.

Last tested 2026-10-15 with Node 22.18.0, node-html-to-image 5.0.0 (bundled Puppeteer 23.2.2, Chrome 128.0.6613.119), satori 0.57.2 and @resvg/resvg-js 2.6.2 on macOS (Apple M1 Pro).

Frequently asked questions

Is satori faster than node-html-to-image?
Yes, by a wide margin in our test. A warm satori render of a 1200x630 card took about 163 ms including PNG conversion with resvg, and the SVG step alone took about 3 ms. node-html-to-image took about 2.3 s for a single card because it starts Chrome, and about 170 ms per card in a batch of 20 once we changed its wait setting.
Does satori support CSS grid?
Version 0.57 does. Its README lists Flexbox, Grid and block layout through the Taffy layout engine, CSS variables and stylesheets. Older articles that say satori is flexbox-only describe earlier versions.
Why does my node-html-to-image card have a seam or a cut-off background?
The default Puppeteer viewport is 800x600. A body background on a 1200px card is painted relative to that viewport, so in our test the gradient repeated at 800px. Set puppeteerArgs.defaultViewport to the card size.
Can satori render emoji?
Only if you give it images for them. Without a loadAdditionalAsset or graphemeImages setup, our rocket emoji rendered as an empty box.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.