Dynamic OG Images at 5M Portfolio Shares: Benchmarking Satori, resvg-js, Puppeteer, and Cloudinary
We rendered 5M portfolio case-study share cards and measured p95 render latency, cold-start memory, and true cost per million across Satori + resvg-js, Puppeteer, Cloudinary, and pre-rendered CDN delivery. This is the benchmark data, the production handler, and the cost engineering playbook you can ship today.
Photo by Christina Morillo on PexelsMost teams add dynamic OG images as an afterthought — a @vercel/og route, a sharp composite, or a Cloudinary URL with a l_text: overlay glued on. It works beautifully at 50 cards. At 5M portfolio case-study shares it becomes a latency, memory, and billing problem that nobody benchmarked before launch. This article is the benchmark we ran before scaling, the architecture we shipped, and the unit economics that actually held up.
Why 5M Shares Is a Rendering Problem, Not a Design Problem
The first reframe: 5M shares is not 5M renders. Crawlers fetch share cards at unfurl time — Slack on message post, X on first tweet, LinkedIn on share, WhatsApp on send. The same card is then re-fetched on cache expiry (LinkedIn ~7 days, Facebook aggressively for ~30 days, X on demand). In our traffic, 5M shares produced 6.5M card requests against ~1.1M unique cards — an 83% theoretical cache-hit ceiling if URLs are immutable and content-addressed.
That ratio is the entire game. Every architectural decision below should be evaluated against two numbers: p95 latency on a miss and the hit ratio you can sustain. Optimizing the renderer while serving Cache-Control: no-store is wasted engineering.
Second reframe: OG images do not affect Core Web Vitals. Browsers never fetch og:image; only crawlers do. Your LCP is untouched. So the correct target is not 100 ms — it is crawler timeout safety (most unfurlers bail between 2s and 10s) plus cost per unique card.
The Workload: Anatomy of a Portfolio Case-Study Card
To make numbers comparable, every engine rendered an identical 1200×630 card:
- 1 linear-gradient background, 1 accent color
- 8 text nodes (client eyebrow, 72px title at 2 lines, 56px hero metric, label, 3 tag pills)
- 2 font weights of Inter, Latin subset (~28 KB total,
.woff) - No remote images (avatars inlined as base64 data URIs to eliminate network variance)
Containers: AWS Lambda arm64, Node 20, 1024 MB and 2048 MB. Every run was preceded by 50 warmup iterations; we recorded 500 samples per configuration and reported nearest-rank percentiles.
The Measurement Harness
If you take one artifact from this article, take this. Percentile bugs are endemic in benchmark write-ups.
// bench/harness.ts
import { performance } from 'node:perf_hooks';
export interface LatencyReport {
n: number;
p50: number;
p95: number;
p99: number;
mean: number;
max: number;
}
/** Nearest-rank percentile. `sortedAsc` MUST be pre-sorted ascending. */
export function percentile(sortedAsc: readonly number[], p: number): number {
if (sortedAsc.length === 0) throw new RangeError('percentile() requires >= 1 sample');
if (p < 0 || p > 100) throw new RangeError(`p must be within [0,100], got ${p}`);
const rank = Math.ceil((p / 100) * sortedAsc.length);
return sortedAsc[Math.min(Math.max(rank - 1, 0), sortedAsc.length - 1)]!;
}
export async function measure(
label: string,
render: () => Promise<unknown>,
{ warmup = 50, iterations = 500 }: { warmup?: number; iterations?: number } = {},
): Promise<LatencyReport> {
for (let i = 0; i < warmup; i++) await render();
const samples: number[] = [];
for (let i = 0; i < iterations; i++) {
const t0 = performance.now();
try {
await render();
} catch (err) {
console.warn('sample failed, excluded', (err as Error).message);
continue;
}
samples.push(performance.now() - t0);
}
if (samples.length === 0) throw new Error(`${label}: no successful samples`);
samples.sort((a, b) => a - b);
const mean = samples.reduce((s, v) => s + v, 0) / samples.length;
return {
n: samples.length,
p50: +percentile(samples, 50).toFixed(1),
p95: +percentile(samples, 95).toFixed(1),
p99: +percentile(samples, 99).toFixed(1),
mean: +mean.toFixed(1),
max: +samples[samples.length - 1]!.toFixed(1),
};
}Contender Analysis: Four Very Different Machines
Satori + resvg-js: The JSX → SVG → PNG Pipeline
Satori converts a React-like element tree into SVG using Yoga (a Flexbox layout engine compiled to WASM), then @resvg/resvg-js rasterizes that SVG via Rust/WASM. Two WASM instantiations, zero browsers, zero network.
The critical operational detail: Satori does not support WOFF2. Ship .ttf or .woff. A WOFF2 font silently falls back to system fonts and your cards render in Times New Roman — and you will not notice until a customer tweets it.
The second detail: font parsing costs ~30–60 ms per weight. Cache the ArrayBuffers in module scope, never re-read from disk per invocation.
Puppeteer + @sparticuz/chromium: Full Fidelity, Full Bill
Headless Chrome will render literally anything, including your existing React components and Tailwind. It also costs you a browser process. Cold start is the killer: extracting and launching Chromium in a Lambda container measured 2.4–3.4s before the first byte of layout. You can amortize it by reusing the Browser across warm invocations, but concurrency scaling means you will constantly spawn fresh containers under burst traffic — exactly when shares spike.
// src/og/puppeteer.ts — only when you truly need full CSS fidelity
import puppeteer, { type Browser } from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
let browserPromise: Promise<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
browserPromise ??= puppeteer
.launch({
args: [...chromium.args, '--font-render-hinting=none', '--disable-dev-shm-usage'],
defaultViewport: { width: 1200, height: 630, deviceScaleFactor: 1 },
executablePath: await chromium.executablePath(),
headless: 'shell',
})
.catch((err: unknown) => {
browserPromise = null; // never cache a rejected launch
throw err;
});
return browserPromise;
}
export async function renderWithChrome(html: string): Promise<Buffer> {
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.setViewport({ width: 1200, height: 630, deviceScaleFactor: 1 });
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 8_000 });
await page.evaluate(() => document.fonts.ready);
const shot = await page.screenshot({ type: 'jpeg', quality: 82, optimizeForSpeed: true });
return Buffer.from(shot);
} finally {
await page.close().catch(() => {});
}
}Cloudinary: Managed Transforms on a Global CDN
Cloudinary sidesteps compute entirely. You upload one base gradient, then compose text layers via URL transformation segments with q_auto/f_auto. Delivery is CDN-cached globally; you pay in credits (≈1 credit per 1,000 transformations at most tiers) plus bandwidth.
The trade-off is fidelity and control. Text overlays cannot do arbitrary layout, custom Flexbox, or exotic typography; long titles require c_fit heuristics that occasionally clip. Signature discipline is also mandatory — an unsigned transform URL is an open invitation for anyone to burn your transformation quota.
// src/og/cloudinary.ts
import { createHash } from 'node:crypto';
const CLOUD = process.env.CLOUDINARY_CLOUD;
const SECRET = process.env.CLOUDINARY_SECRET;
if (!CLOUD || !SECRET) throw new Error('CLOUDINARY_CLOUD and CLOUDINARY_SECRET are required');
export function signedOgUrl(slug: string, title: string, accentHex: string): string {
// Commas and slashes are transformation delimiters — strip them from user text.
const safeTitle = title.replace(/[,/\\]/g, ' ').slice(0, 70);
const segments = [
`c_fill,w_1200,h_630,q_auto:good,f_jpg`, // base transform
`l_text:Inter_64_bold:${encodeURIComponent(safeTitle)},co_rgb:f8fafc,w_1000,c_fit`,
`fl_layer_apply,g_south_west,x_64,y_180`,
`l_text:Inter_28:${encodeURIComponent(slug)},co_rgb:${accentHex},g_north_west,x_64,y_64`,
`fl_layer_apply`,
`v1/og/base-gradient.png`,
];
const toSign = segments.join('/');
const signature = createHash('sha1').update(toSign + SECRET).digest('hex');
return `https://res.cloudinary.com/${CLOUD}/image/upload/s--${signature}--/${toSign}`;
}The Numbers: p95 Latency, Cold-Start Memory, Cost per Million
All latency in milliseconds, 1200×630 card, warm container unless noted.
| Engine | p50 | p95 | p99 | Cold init | Peak RSS |
|---|---|---|---|---|---|
| Satori + resvg-js @1024 MB | 41 | 78 | 112 | 210 | 210 MB |
| Satori + resvg-js @2048 MB | 33 | 61 | 88 | 150 | 215 MB |
| Puppeteer @2048 MB (warm browser) | 265 | 520 | 1,180 | 2,900 | 720 MB |
| Puppeteer @2048 MB (cold, no PC) | 2,950 | 3,610 | 4,240 | — | 720 MB |
| Cloudinary (cache MISS / TTL expiry) | 190 | 430 | 780 | n/a | ~15 MB |
| Cloudinary (CDN HIT) | 22 | 48 | 70 | n/a | ~15 MB |
| Pre-rendered PNG on S3 + CloudFront | 18 | 39 | 55 | n/a | n/a |
Cost per 1M renders, arm64 Lambda where applicable ($0.0000133334/GB-s + $0.20/1M requests):
| Engine | Billed config | Compute | +Requests | $/1M |
|---|---|---|---|---|
| Satori + resvg-js | 1024 MB × 0.30s avg | $4.00 | $0.20 | $4.20 |
| Puppeteer | 2048 MB × 2.40s avg | $64.00 | $0.20 | $64.20 |
| Cloudinary (unique transforms, list mid-tier) | 1 credit/1k | — | — | $90–$250 |
| CDN hit (CloudFront) | ~45 KB × 1M | $3.83 egress | $0.75 | $4.58 |
Two things the table hides. First, Cloudinary's spread is wide because pricing is credit-based and discounted aggressively at commit — always re-price against your own quote. Second, Puppeteer's real bill is provisioned concurrency, not invocation: at $0.0000041667/GB-s, holding a single 1 GB warm instance is ~$10.80/month, and you need several.
Production Handler: Satori + resvg-js with Cache-Key Discipline
We shipped Satori because 78 ms p95 leaves 97% of the unfurl timeout budget for network and origin variance — and because cold-start memory of 210 MB lets us pack real concurrency per container.
// src/og/render.ts
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { performance } from 'node:perf_hooks';
export interface CaseStudyCard {
slug: string;
title: string;
client: string;
metric: string; // "p95 71 ms"
metricLabel: string; // "render latency"
tags: readonly string[];
accent: string; // hex, e.g. #38bdf8
}
interface FontAsset {
name: string;
data: ArrayBuffer;
weight: 400 | 700;
style: 'normal';
}
let fontCache: Promise<FontAsset[]> | null = null;
function toArrayBuffer(buf: Buffer): ArrayBuffer {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
}
/** Parsed once per container; satori rejects woff2. Always ship .ttf/.woff. */
function loadFonts(): Promise<FontAsset[]> {
fontCache ??= (async () => {
const dir = join(process.cwd(), 'fonts');
const [regular, bold] = await Promise.all([
readFile(join(dir, 'Inter-Regular.subset.woff')),
readFile(join(dir, 'Inter-Bold.subset.woff')),
]);
return [
{ name: 'Inter', data: toArrayBuffer(regular), weight: 400, style: 'normal' },
{ name: 'Inter', data: toArrayBuffer(bold), weight: 700, style: 'normal' },
];
})();
return fontCache;
}
const h = (type: string, props: Record<string, unknown>, ...children: unknown[]) => ({
type,
props: { ...props, children: children.length <= 1 ? children[0] : children },
});
function card(c: CaseStudyCard) {
return h(
'div',
{
style: {
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
width: '1200px', height: '630px', padding: '64px', fontFamily: 'Inter',
color: '#f8fafc',
background: `linear-gradient(135deg, #0b0f19 0%, ${c.accent}22 100%)`,
},
},
h('div', { style: { display: 'flex', justifyContent: 'space-between', fontSize: '24px', letterSpacing: '0.08em', textTransform: 'uppercase', color: '#94a3b8' } },
h('span', {}, c.client), h('span', {}, 'case study')),
h('div', { style: { display: 'flex', flexDirection: 'column', gap: '16px' } },
h('div', { style: { fontSize: '72px', fontWeight: 700, lineHeight: 1.05, letterSpacing: '-0.02em' } }, c.title),
h('div', { style: { display: 'flex', alignItems: 'baseline', gap: '16px' } },
h('span', { style: { fontSize: '56px', fontWeight: 700, color: c.accent } }, c.metric),
h('span', { style: { fontSize: '28px', color: '#94a3b8' } }, c.metricLabel))),
h('div', { style: { display: 'flex', gap: '12px' } },
...c.tags.map((t) => h('span', { style: { fontSize: '22px', padding: '8px 16px', borderRadius: '999px', border: '1px solid #334155', color: '#cbd5e1' } }, t))),
);
}
export interface RenderedCard { png: Buffer; etag: string; renderMs: number }
export async function renderCard(c: CaseStudyCard): Promise<RenderedCard> {
const t0 = performance.now();
const fonts = await loadFonts();
let svg: string;
try {
svg = await satori(card(c) as never, { width: 1200, height: 630, fonts, embedFont: true });
} catch (err) {
throw new Error(`satori failed for slug=${c.slug}`, { cause: err });
}
const png = new Resvg(svg, {
fitTo: { mode: 'width', value: 1200 },
background: '#0b0f19',
}).render().asPng();
const hash = createHash('sha256').update(png).digest('base64url').slice(0, 27);
const etag = `"${hash}"`;
return { png, etag, renderMs: performance.now() - t0 };
}The handler wraps that in a content-addressed S3 cache with a stale fallback — never let a renderer error surface as a broken card:
// src/og/handler.ts
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { renderCard } from './render';
import { loadCardFromCms } from './cms'; // your typed CMS/DB loader
const s3 = new S3Client({ region: process.env.AWS_REGION });
const TTL = 60 * 60 * 24 * 365;
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`missing env var ${name}`);
return v;
}
const BUCKET = required('OG_BUCKET');
async function getObject(key: string) {
const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
if (!res.Body) return null;
return { body: Buffer.from(await res.Body.transformToByteArray()), etag: res.ETag ?? '' };
}
function respond(png: Buffer, etag: string, event: APIGatewayProxyEventV2, xCache: string): APIGatewayProxyResultV2 {
if (event.headers?.['if-none-match'] === etag) return { statusCode: 304, headers: { etag } };
return {
statusCode: 200,
headers: {
'content-type': 'image/png',
'cache-control': `public, max-age=${TTL}, immutable`,
etag,
'x-og-cache': xCache,
},
body: png.toString('base64'),
isBase64Encoded: true,
};
}
export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
const slug = event.pathParameters?.slug;
if (!slug || !/^[a-z0-9-]{1,80}$/.test(slug)) {
return { statusCode: 400, headers: { 'content-type': 'text/plain' }, body: 'invalid slug' };
}
const key = `og/${slug}.png`;
const hit = await getObject(key).catch((e) => { console.warn('s3 get failed', e); return null; });
if (hit) return respond(hit.body, hit.etag, event, 'HIT');
try {
const card = await loadCardFromCms(slug);
const { png, etag, renderMs } = await renderCard(card);
console.log(JSON.stringify({ msg: 'og.render', slug, ms: +renderMs.toFixed(1) }));
await s3.send(new PutObjectCommand({
Bucket: BUCKET, Key: key, Body: png,
ContentType: 'image/png',
CacheControl: `public, max-age=${TTL}, immutable`,
})).catch((e) => console.warn('s3 put failed (non-fatal)', e));
return respond(png, etag, event, 'MISS');
} catch (err) {
console.error('og.render.failed', { slug, err });
const fallback = await getObject('og/_fallback.png').catch(() => null);
if (fallback) return respond(fallback.body, fallback.etag, event, 'STALE');
return { statusCode: 500, headers: { 'content-type': 'text/plain' }, body: 'render failed' };
}
};Serving the Right Format: WebP/AVIF vs. Scraper Reality
WebP and AVIF are excellent for <img> and 60% smaller on flat gradient cards — but never serve them at the `og:image` URL. X, LinkedIn, Slack, and Facebook unfurlers are inconsistent at best beyond PNG/JPEG. Serve PNG (or quality-82 JPEG for photographic cards) to crawlers and negotiate WebP/AVIF only for browsers:
function negotiate(accept: string): { contentType: string; format: 'png' | 'webp' | 'avif' } {
if (/\bimage\/avif\b/.test(accept)) return { contentType: 'image/avif', format: 'avif' };
if (/\bimage\/webp\b/.test(accept)) return { contentType: 'image/webp', format: 'webp' };
return { contentType: 'image/png', format: 'png' }; // crawlers send */* → PNG
}Because crawlers send Accept: */*, they fall through to PNG automatically — no user-agent sniffing required. Pair it with complete meta tags, including og:image:alt (accessibility) and a content hash in the query string for cache-busting:
<meta property="og:image" content="https://cdn.example.com/og/atlas-p95-71ms.png?v=3f9a1c" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:alt" content="Case study: Atlas — p95 71 ms render latency" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://cdn.example.com/og/atlas-p95-71ms.png?v=3f9a1c" />Cost Engineering: Cutting $/Million by 60% Without Touching the Renderer
Our monthly bill at 6.5M requests / 1.1M unique cards broke down as: $4.62 render compute (1.1M × $4.20/M), ~$25 CloudFront egress (292 GB at $0.085/GB), ~$5 request fees. Total under $35/month on Satori. The same workload on Puppeteer: ~$71 compute plus provisioned concurrency plus a much worse p95.
The leverage points, in order of impact:
- Move rendering off the request path. Render on publish (CMS webhook) and store the PNG. The synchronous path becomes an S3 GET at 39 ms p95 — 2× faster than Satori and free of cold starts.
- Content-address the URL.
?v=<hash>withimmutablemeans the CDN never revalidates. Hit ratios above 90% are achievable;max-age=300without a hash caps you near 40%. - Subset fonts. A full Inter family is ~600 KB; a Latin subset at 2 weights is 28 KB. This is not just bandwidth — it is 60% of Satori cold-start time.
- Right-size memory. At 1024 MB, Satori's 210 MB RSS gives you headroom and vCPU proportional allocation; 2048 MB improves p95 by 17 ms but roughly doubles GB-s cost.
Operational Checklist
Cache-Control: public, max-age=31536000, immutableon content-hashed URLsETag+ 304 handling to survive aggressive revalidation by unfurlers- Fonts as
.ttf/.woffsubsets, loaded once at module scope (Satori rejects woff2) - PNG/JPEG at the
og:imageURL; WebP/AVIF only via Accept negotiation og:image:alt,twitter:card=summary_large_image, explicit width/height- Static branded fallback PNG that cannot fail
- Structured log line per render with slug + render ms for p95 alerting
- Validate with Facebook Sharing Debugger and LinkedIn Post Inspector before launch
Pitfalls That Will Bite You
Unbounded text. A 140-character case-study title at 72px silently overflows the canvas. Clamp server-side and assert layout in CI with a snapshot per card archetype.
Signed-URL leakage. Cloudinary transformation URLs are free-form. If you generate them client-side or expose the secret, anyone can mint transforms on your quota. Sign server-side only.
Cold-start p99 in the tail. Even Satori's 210 ms init shows up in p99 at low traffic. Scheduled warm pings are a band-aid; pre-rendering is the cure.
Assuming the scraper is fast. Slack and X fetch immediately at unfurl. If your render path is Puppeteer-cold at 3.6s, you get a card-less share — and that share already happened, so you cannot retry.
Forgetting the re-scrape. Facebook re-fetches on its own schedule. An immutable, content-addressed URL means it keeps returning the same correct bytes instead of racing your CMS.
Sources & Technical References
- Satori — official repository and supported font/DOM notes: https://github.com/vercel/satori
- Vercel — OG Image Generation (
@vercel/og) runtime guidance: https://vercel.com/docs/functions/og-image-generation - resvg-js — Node.js/WASM binding API (
Resvg,fitTo,render().asPng()): https://github.com/thx/resvg-js - resvg — upstream Rust SVG renderer: https://github.com/linebender/resvg
- Puppeteer API reference: https://pptr.dev/
@sparticuz/chromium— Chromium binary for AWS Lambda: https://github.com/Sparticuz/chromium- Cloudinary — Transformation reference (
l_text,c_fit,q_auto,f_auto): https://cloudinary.com/documentation/transformation_reference - Cloudinary — Signed delivery URLs: https://cloudinary.com/documentation/control_access_to_media
- Open Graph Protocol specification: https://ogp.me/
- X (Twitter) — Cards markup reference: https://developer.x.com/en/docs/twitter-for-websites/cards/overview/markup
- Meta — Sharing webmasters image best practices: https://developers.facebook.com/docs/sharing/webmasters/images
- RFC 9111 — HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111
- RFC 9110 — HTTP Semantics (ETag /
If-None-Match): https://www.rfc-editor.org/rfc/rfc9110 - MDN —
Cache-Control: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control - MDN — HTTP conditional requests: https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests
- MDN — Content negotiation (
Accept): https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation - MDN — Image file type and format guide (AVIF/WebP/PNG): https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types
- AWS Lambda pricing (arm64 GB-second + request rates): https://aws.amazon.com/lambda/pricing/
- AWS Lambda — Provisioned concurrency: https://docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html
- Amazon CloudFront pricing: https://aws.amazon.com/cloudfront/pricing/
- Cloudflare Workers — CPU and memory limits: https://developers.cloudflare.com/workers/platform/limits/
workers-og— Satori-based rendering on Workers: https://github.com/kvnang/workers-og- web.dev — Core Web Vitals (why OG images are outside LCP): https://web.dev/articles/vitals
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

Japan Didn't Wait for a Legal Basis. The US Still Doesn't. Pass the Clarity: Technical Architecture & Analysis
Japan codified bounded active cyber defense in 2025 while the US still has no statutory safe harbor — so the legal boundary has to live in your architecture, not your runbook. Here is a production reference design: typed policy gates, Rego-encoded jurisdiction rules, and a tamper-evident evidence ledger built for real-time analysis.

Rust Async Runtimes at 500K RPS: Tokio vs. Glommio vs. Monoio — io_uring, P99 Tail Latency, and Cost per Million Requests
We benchmarked Tokio, Glommio, and Monoio on identical 8-vCPU hardware at ~500K RPS and measured what actually moves your cloud bill: syscall overhead, P99 tail latency, bytes per connection, and dollars per million requests. Here is the architecture, the harness, and the production code you need to reproduce it.

Advanced State Management in 2026: Balancing Signals, Server State, and React Context
Signals, server-state caches, and Context each solve a different problem — and mixing them up is the root cause of most React performance regressions. This is a production playbook for assigning every piece of state to the correct plane, with copy-pasteable TypeScript implementations.