OG Image Caching: Speed Up Dynamic Social Cards
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.
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.
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.
| Strategy | Cache header | Invalidation | Best for |
|---|---|---|---|
| Versioned URL | immutable, max-age=31536000 | Bump version token in URL | Cards that change rarely (most blogs) |
| Stale-while-revalidate | max-age=3600, stale-while-revalidate=86400 | Background re-render on next hit | Cards that change but tolerate brief staleness |
| Short TTL | max-age=300 | Automatic expiry | Time-sensitive cards (live counts, dates) |
| No cache | no-store | N/A — renders every time | Never 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:
- Request comes in for
/og/my-post?v=7. - Check blob storage for the key
my-post-v7.png. Hit → stream it back, done. - 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.
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.