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 fordocument.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.