Dynamic OG image generation

OG Image Caching: Speed Up Dynamic Social Cards

By Social Card Studio6 min read

Cache a dynamic OG image and it renders once, not on every share. Set a Cache-Control header with a long max-age, key the cache on the post slug plus a brand version, and a post shared ten thousand times triggers exactly one Satori render instead of ten thousand. Everything below is the mechanics: the headers that matter, how to build a key that invalidates cleanly, and where to store the rendered PNG so even a cold edge function never re-renders.

Why does a dynamic OG image need caching at all?

Because generating one is expensive, and link previews are requested far more often than you'd think. Every time a URL is shared, the receiving platform's scraper fetches og:image to build the card — and scrapers don't share a cache with each other. A single popular post can be hit by Facebook's crawler, X's bot, Slack's unfurler, and a few hundred human page loads, each one a candidate to re-run your renderer.

100–400 ms
is the typical time to render a single 1200×630 card with Satori in an edge function — paid on every uncached request. @vercel/og / Satori, per-render benchmark

That cost is invisible until a post goes viral. Without a cache, render time and compute bill both scale linearly with shares. With one, they flatten: the first request renders, every request after is a static file served from the CDN.

~+114%
more impressions go to posts that ship an image versus none — which is exactly why the card has to load fast and never break. CXL, click-through benchmarks

What Cache-Control header should an OG image use?

For a card whose pixels never change for a given URL, cache it aggressively. The strongest header tells the CDN and every scraper to keep the PNG for a year and never revalidate:

Cache-Control: public, immutable, no-transform, max-age=31536000
  • public — let shared caches (CDNs, proxies) store it, not just the browser.
  • immutable — promise the bytes won't change for this URL, so the client skips even conditional revalidation.
  • no-transform — stop intermediaries from re-compressing the image and wrecking your file size.
  • max-age=31536000 — one year, the conventional "cache forever" value.

This is safe only because the URL itself encodes the content version (next section). If the same URL can ever return different pixels, immutable will pin a stale card.

How do you build a cache key that invalidates cleanly?

Put the version in the URL. The cleanest pattern keys the image on the post slug plus a brand-kit version token:

/og/my-first-post?v=7

When nothing changes, the URL is identical on every request and the CDN serves the stored PNG. When the title, logo, or brand colors change, you bump v to 8, the URL changes, and caches treat it as a brand-new asset — no purge API, no waiting for a TTL to expire. The old ?v=7 entry simply ages out on its own.

This is the same invariant Social Card Studio enforces internally: every brand-kit write increments a version integer, and that version flows into the card's identity so a kit edit produces a fresh card without a manual cache flush.

StrategyCache headerInvalidationBest for
Versioned URLimmutable, max-age=31536000Bump version token in URLCards that change rarely (most blogs)
Stale-while-revalidatemax-age=3600, stale-while-revalidate=86400Background re-render on next hitCards that change but tolerate brief staleness
Short TTLmax-age=300Automatic expiryTime-sensitive cards (live counts, dates)
No cacheno-storeN/A — renders every timeNever use for OG images

When should you use stale-while-revalidate instead?

When the card can change but you can tolerate serving a slightly old one for a moment. stale-while-revalidate lets the CDN return the cached card instantly while it regenerates a fresh copy in the background:

Cache-Control: public, max-age=3600, stale-while-revalidate=86400

For an hour the card is fresh. For the next 24 hours the CDN serves the stale card immediately on each request and triggers one background re-render. No visitor ever waits on Satori. The tradeoff is honesty: the first viewer after a change may see the old card for up to a day. For most marketing cards that's fine; for a card showing a live number, use a short max-age instead.

Where should the rendered PNG actually live?

The header tells caches how long to keep the image; it doesn't help a cold edge function that has nothing cached. For that, persist the rendered bytes to object storage on first render and serve from there:

  1. Request comes in for /og/my-post?v=7.
  2. Check blob storage for the key my-post-v7.png. Hit → stream it back, done.
  3. Miss → render with Satori once, write the PNG to storage under that key, then return it.

Now even a fresh function instance with an empty in-memory cache skips rendering — it reads a finished PNG from storage. Combined with a long Cache-Control header, you get three layers: scraper/browser cache, CDN cache, and a storage-backed origin that only ever renders each unique card once.

100–200 KB
is the target size for the PNG you cache; a well-compressed 1200×630 card lands around 150 KB, so storing every version costs almost nothing. MyOG Image, 2025

Why does my card still show the old image after I fixed it?

Because social platforms keep their own cache, independent of yours. Even with perfect headers, Facebook, LinkedIn, and X store a scraped copy of your preview. Bumping your version token changes the URL — but a platform that already cached the page may not re-scrape until you force it.

The fix is two-sided: version the image URL so your CDN serves fresh bytes, then re-scrape the page URL in each platform's debugger to clear theirs. The full walkthrough for stubborn previews lives in Why Your Link Preview Is Broken.

The takeaway

Render once, serve forever. Set Cache-Control: public, immutable, max-age=31536000, key the image on slug plus a version token, persist the PNG to storage on first render, and bump the version whenever content changes. That turns dynamic OG generation from a per-request cost into a one-time one — the model behind how the dynamic OG image API and @vercel/og tutorial approaches scale. For the exact dimensions and file-size ceilings of the card you're caching, keep the social media image size cheat sheet handy. Generating, versioning, and caching a branded 1200×630 card for every post automatically is exactly what Social Card Studio does.

Frequently asked questions

How do I cache a dynamic OG image?

Render once, then serve from cache. Set a Cache-Control header like 'public, immutable, no-transform, max-age=31536000' on your OG response, and key the cache on the post slug plus a brand version. The image regenerates only when the slug or version changes, so a viral post that gets shared 10,000 times still triggers exactly one render.

What Cache-Control header should an OG image use?

For a card whose content never changes for a given URL, use a long max-age with immutable: 'public, immutable, no-transform, max-age=31536000'. If the card can change, drop max-age (e.g. 3600) and add stale-while-revalidate so the CDN serves the old card instantly while regenerating in the background.

Why is my dynamic OG image slow to load?

Because it regenerates on every request instead of being cached. Satori/@vercel/og rendering takes 100-400 ms per call; without a cache header, every scraper hit and every share pays that cost. Add a Cache-Control header and a stable cache key so the CDN serves a stored PNG after the first render.

How do I invalidate a cached OG image when the title changes?

Put a version token in the image URL or cache key. When the post title, logo, or brand colors change, bump the version (a timestamp or an incrementing integer) so the URL changes and the CDN treats it as a new asset. Social platforms also cache previews, so re-scrape the URL in their debugger after a change.

← All posts