All ArticlesPortfolio•October 31, 2026•11 min read

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.

Woman using a laptop in a server room, showcasing modern technology and work environment.Photo by Christina Morillo on Pexels

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

ts
// 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.

ts
// 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.

ts
// 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.

Enginep50p95p99Cold initPeak RSS
Satori + resvg-js @1024 MB4178112210210 MB
Satori + resvg-js @2048 MB336188150215 MB
Puppeteer @2048 MB (warm browser)2655201,1802,900720 MB
Puppeteer @2048 MB (cold, no PC)2,9503,6104,240—720 MB
Cloudinary (cache MISS / TTL expiry)190430780n/a~15 MB
Cloudinary (CDN HIT)224870n/a~15 MB
Pre-rendered PNG on S3 + CloudFront183955n/an/a

Cost per 1M renders, arm64 Lambda where applicable ($0.0000133334/GB-s + $0.20/1M requests):

EngineBilled configCompute+Requests$/1M
Satori + resvg-js1024 MB × 0.30s avg$4.00$0.20$4.20
Puppeteer2048 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.

ts
// 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:

ts
// 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:

ts
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:

html
<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:

  1. 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.
  2. Content-address the URL. ?v=<hash> with immutable means the CDN never revalidates. Hit ratios above 90% are achievable; max-age=300 without a hash caps you near 40%.
  3. 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.
  4. 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, immutable on content-hashed URLs
  • ETag + 304 handling to survive aggressive revalidation by unfurlers
  • Fonts as .ttf/.woff subsets, loaded once at module scope (Satori rejects woff2)
  • PNG/JPEG at the og:image URL; 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

0 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles