Satori vs Puppeteer for OG Images: One HTML, Two Engines

Six OG card templates, one HTML source, rendered by Satori and by Chromium via Puppeteer: CSS support matrix, cold and warm timings, bytes and fonts.

By Simon Chiu · Updated

Disclosure: RenderScreenshot is our product, and it renders with Chromium. Everything on this page was measured on our own machine with open-source tools; no part of it uses our API.

Satori and Puppeteer solve the same problem in different ways. Satori is a layout engine written for one job: it turns a tree of elements with inline styles into SVG, and a second library (usually resvg) turns that SVG into a PNG. Puppeteer drives a real Chromium, so the card is a web page and the PNG is a screenshot. The question for an OG image is not which is "better" but which trade-off you want: a small engine with a CSS subset, or a full browser you have to run.

This page answers it with one experiment. We wrote six card templates once, as HTML, and rendered the same string with both engines. Satori got the HTML through satori-html, which is how many teams feed it markup they already have. The production guide to OG images covers the rest of the pipeline: meta tags, signing and caching.

Test setup

  • Machine: Apple M1 Pro, macOS, Node 22.18.0, 2026-10-18.
  • Satori side: [email protected], [email protected], @resvg/[email protected]. Fonts: Inter 400 and 700 from @fontsource/[email protected] (WOFF), passed as buffers that were read once at start-up.
  • Chromium side: [email protected] with Chrome 155.0.8059.79. The same Inter WOFF files embedded with @font-face. One page, reused: setContent, wait for document.fonts.ready, screenshot.
  • Card size: 1200x630, device scale factor 1, PNG.
  • Runs: one first render per template, then 20 timed renders. We report p50 and p95.

The Vercel OG alternative page ran a related test with hand-written Satori element trees and Playwright's Chromium. This page differs in three ways: one HTML source for both engines, a split of Satori's time into layout and rasterizing, and a feature matrix beyond the six cards.

Result 1: what each engine drew

Template What it tests Satori 0.62.0 Chromium 155
Basic title Flexbox column, two font weights Same as Chromium Correct
Gradient and shadow linear-gradient, border-radius, box-shadow, letter-spacing, text-transform Same as Chromium Correct
CSS grid grid-template-columns: 1fr 1fr 1fr, grid-column: span 3, gap Same as Chromium Correct
Emoji and CJK Two emoji, Japanese and Korean text, Latin font only Boxes for every emoji and CJK glyph, no error Correct, from macOS system fonts
Clamp and ellipsis -webkit-line-clamp: 3 on a long title Same as Chromium, ellipsis on line 3 Correct
Image, filter, blend Inline SVG avatar, filter: grayscale(), position: absolute, mix-blend-mode, opacity Same as Chromium Correct

Five of six matched by eye at full size. The sixth is about fonts, not layout, and it would fail in Chromium too on a Linux server without CJK and emoji fonts. On Debian or Ubuntu that means packages such as fonts-noto-cjk and fonts-noto-color-emoji; our emoji rendering guide explains which emoji font our own renderer uses.

Result 2: the feature matrix

We then rendered one small tile per feature through the same satori-html path and checked each PNG.

Feature Satori 0.62.0 via satori-html Notes
calc(100% - 40px) width Correct
CSS custom property (--brand, var(--brand)) Not applied, no error satori-html rewrote the name to -Brand. Passed as a style object straight to Satori, the variable worked
z-index on absolute boxes Correct stacking
text-shadow Correct
transform: rotate(15deg) Correct
backdrop-filter: blur(8px) Wrong: a blurred black square, not a blur of the background Chromium blurs what is behind the box
<style> element with a class Applied
Inline <svg> Correct
grid-template-columns: 2fr 1fr Exact split (400 px and 200 px of 600)
Block div with two div children, no display Stacked, like Chromium Older versions required display: flex
WOFF2 font buffer Text drawn

Two of eleven were wrong and neither raised an error. That is the pattern to plan for: when Satori does not support something, it usually draws something else. A preview check in CI (render, then compare against a reference PNG) catches it; a try/catch does not.

Result 3: time and bytes

All times in milliseconds. "Satori total" is satori() plus resvg; the two columns before it split that time.

Template Satori layout p50 resvg raster p50 Satori total p50 / p95 Chromium p50 / p95 PNG bytes Satori / Chromium
Basic title 2.8 182.5 188.8 / 196.1 61.6 / 77.3 31,082 / 37,380
Gradient and shadow 1.9 533.5 535.1 / 612.5 105.4 / 125.5 148,852 / 134,810
CSS grid 2.6 180.9 183.8 / 200.2 57.6 / 71.8 20,428 / 17,809
Emoji and CJK 1.6 178.6 180.9 / 193.5 51.3 / 66.8 18,592 / 37,023
Clamp and ellipsis 2.1 187.6 189.5 / 203.1 52.1 / 56.5 43,452 / 41,060
Image, filter, blend 2.2 191.3 194.1 / 218.7 51.2 / 61.9 24,787 / 26,211

Cold start. The first Satori render in the process took 1,511 ms, most of it loading the WebAssembly layout engine. Later first renders of new templates took 180 to 645 ms. Launching Chromium took 1,076 ms, and its first render of each template then took 47 to 95 ms.

What the numbers say:

  • Satori's layout is almost free; rasterizing is the cost. About 2 ms of each Satori render was layout. The rest was resvg drawing 756,000 pixels on the CPU. The gradient-and-shadow card took three times longer because a large blurred shadow is expensive to rasterize.
  • A warm browser is faster per card. Once Chromium runs, it beat Satori and resvg on every template, by 3 to 5 times at p50.
  • Satori starts cheaper. No browser process, no 1 s launch, less memory. In a serverless function that renders one card and exits, the comparison is Satori's first render against Chromium's launch plus first render, and Satori wins that.
  • Bytes are a wash. Both produce similar PNGs. The emoji card is smaller from Satori only because it drew boxes instead of glyphs.

The emoji card (the gap in bytes) is a reminder to compare pictures before you compare numbers.

Font handling: the biggest practical difference

Satori Chromium (Puppeteer)
Where fonts come from Only the buffers you pass in fonts @font-face in the page, plus every font installed on the machine
Formats we saw work TTF, OTF, WOFF, and WOFF2 in 0.62.0 Anything Chrome loads
Missing glyph A box. No fallback to system fonts Fallback to an installed font; a box only if no font has the glyph
Emoji Your code supplies images through loadAdditionalAsset or graphemeImages The system emoji font (install fonts-noto-color-emoji on Debian or Ubuntu)
Variable fonts Pass static instances Supported
Same output on laptop and server Yes: the fonts travel with the code Only if the server has the same fonts

Satori's model is strict, and that is also its strength: the card looks the same everywhere, because nothing comes from the machine. Chromium's model is forgiving on a laptop and surprising on a server. Our post on consistent fonts in headless rendering covers how to pin fonts in a container.

Tested code for both sides

Both scripts read the same card.html and write a PNG. They are cut-down versions of the benchmark script.

Satori with satori-html and resvg:

// satori-card.mjs: npm i [email protected] [email protected] @resvg/[email protected] @fontsource/[email protected]
import { readFile, writeFile } from 'node:fs/promises';
import satori from 'satori';
import { html } from 'satori-html';
import { Resvg } from '@resvg/resvg-js';

const font = (w) => readFile(`node_modules/@fontsource/inter/files/inter-latin-${w}-normal.woff`);
const fonts = [
  { name: 'Inter', data: await font(400), weight: 400, style: 'normal' },
  { name: 'Inter', data: await font(700), weight: 700, style: 'normal' },
];

const markup = await readFile('card.html', 'utf8'); // inline styles, no <html> wrapper
const svg = await satori(html(markup), { width: 1200, height: 630, fonts });
const png = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
await writeFile('card-satori.png', png);

Chromium with Puppeteer, one browser for many cards:

// chromium-card.mjs: npm i [email protected] @fontsource/[email protected]
import { readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';

const woff = async (w) => (await readFile(`node_modules/@fontsource/inter/files/inter-latin-${w}-normal.woff`)).toString('base64');
const fontCss = `@font-face{font-family:Inter;font-weight:400;src:url(data:font/woff;base64,${await woff(400)}) format('woff')}
@font-face{font-family:Inter;font-weight:700;src:url(data:font/woff;base64,${await woff(700)}) format('woff')}
html,body{margin:0}`;

const markup = await readFile('card.html', 'utf8');
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1200, height: 630, deviceScaleFactor: 1 });
  await page.setContent(`<!doctype html><meta charset="utf-8"><style>${fontCss}</style>${markup}`, { waitUntil: 'load' });
  await page.evaluate(() => document.fonts.ready);
  await writeFile('card-chromium.png', await page.screenshot({ type: 'png' }));
} finally {
  await browser.close();
}

Tested on 2026-10-18 with Node 22.18.0 and the versions in the install comments. In a server, launch the browser once and reuse the page for each card; launching per card adds about a second each time.

If the card title comes from user input, escape it before it goes into card.html. Both engines will otherwise render whatever markup the title contains.

Decision table

Your situation Pick Why
Cards render in an edge or serverless function, one per request Satori No browser to launch; fonts ship with the code
Templates use only flexbox, text, gradients, images Satori Inside the subset that matched Chromium in our test
Designers write normal CSS: variables, backdrop-filter, any new feature Chromium The card is a web page, so CSS works as on the site
You render many cards in a batch or a long-running worker Chromium Warm renders were 3 to 5 times faster
Cards mix scripts (emoji, CJK, Arabic) from user content Chromium with the fonts installed Font fallback is automatic; in Satori you supply every font
You want the card to reuse your site's real components and CSS Chromium Satori would need a second copy of the design
You do not want to run a browser at all Satori, or a hosted Chromium The trade is CSS coverage against operations

The hosted Chromium route

The cost of the Chromium column is not render time. It is running Chromium: a 1 s cold start, memory, crash recovery, fonts on Linux. A screenshot API moves that part off your servers. With RenderScreenshot, the og_card preset renders HTML or a URL at 1200x630, and the HTML input takes the same markup as the scripts above:

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html": "<div style=\"display:flex;width:1200px;height:630px;align-items:center;justify-content:center;background:#0f172a;color:#fff;font:700 72px system-ui\">Hello</div>", "preset": "og_card"}' \
  -o card.png

We did not time our API for this page; network and queue time would make it a different measurement. Try the same template on your own cards with a free API key, or see what we found when we crawled 10,000 home pages for og:image tags.

Last tested: 2026-10-18 on an Apple M1 Pro with Node 22.18.0, satori 0.62.0, satori-html 0.3.2, @resvg/resvg-js 2.6.2 and puppeteer 25.14.0 (Chrome 155.0.8059.79). Numbers are from one machine and one run of 20 renders per template; your hardware will differ.

Frequently asked questions

Is Satori faster than Puppeteer for OG images?
Not in our test once Chromium was running. Satori's layout step took about 2 ms per card, but turning its SVG into a PNG with resvg took 180 to 530 ms. A warm Puppeteer page rendered the same cards in 51 to 105 ms. Satori wins on start-up: there is no browser to launch.
Can I give Satori the same HTML I give Puppeteer?
Mostly, through satori-html, which turns an HTML string into the element tree Satori expects. Five of our six templates rendered the same in both engines. Watch for CSS custom properties: satori-html 0.3.2 rewrote --brand to -Brand, so var(--brand) had nothing to read, while the same variable worked when we passed a style object to Satori directly.
Why does Satori show boxes for emoji and Japanese text?
Satori only draws glyphs from the fonts you pass to it. With only a Latin font, every emoji and CJK character became a box. Pass a font that has those characters, and use loadAdditionalAsset or graphemeImages for emoji.
Does Satori support CSS grid now?
Satori 0.62.0 rendered our three-column grid and an exact 2fr/1fr split correctly. Earlier versions did not support grid, and a more complex grid card on our Vercel OG alternative page came out wrong, so test your own layout.
Do WOFF2 fonts work in Satori?
In 0.62.0, yes: a WOFF2 file of Inter drew text the same way as the WOFF file. Older guides say only TTF, OTF and WOFF work, so check the version you run.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.