← Back to Blog

How OGImg Caches Rendered OG Images with Cloudflare R2

A content-addressed caching architecture for a dynamic OG image API: hashing render params, checking Cloudflare R2 with a HEAD request, and why cache hits still count against quota.

Generating a dynamic Open Graph image sounds simple until you actually build it: spin up a headless browser, render some HTML, screenshot it, ship a PNG. Do that on every request and you've built a very slow, very expensive image server — a cold Chromium launch plus a real page render easily costs multiple seconds, and social-media crawlers (Slack, Discord, Twitter/X, LinkedIn) will happily hammer the same URL dozens of times in a burst when a link gets shared.

The fix isn't a clever rendering trick — it's not rendering at all, most of the time. This post walks through how /api/render in OGImg turns a set of query parameters into a cached, CDN-served PNG on Cloudflare R2, and a couple of the less obvious tradeoffs that came out of building it.

The core idea: content-addressed caching

Every render request boils down to a fixed set of inputs — template, title, description, author, background/text color, logo URL, watermark flag, font family. If two requests carry the same inputs, they should produce the exact same image, so there's no reason to render twice. That's a textbook case for content-addressed caching: hash the inputs, use the hash as the cache key, and check the cache before doing any expensive work.

src/lib/render/hash.ts
export function hashRenderParams(params: Record<string, string>): string {
  const sorted = Object.keys(params)
    .sort()
    .filter((key) => params[key] !== undefined && params[key] !== "")
    .map((key) => `${key}=${params[key]}`)
    .join("&");

  return createHash("sha256").update(sorted).digest("hex").slice(0, 32);
}

A few small decisions here matter more than they look:

  • Keys are sorted before hashing. The caller can build the params object in any order — ?title=X&bg=Y and ?bg=Y&title=X — and still land on the same hash. Query string order shouldn't be part of your cache key.
  • Empty and undefined values are dropped. ?logoUrl= and no logoUrl at all should be the same cache entry, not two.
  • SHA-256, truncated to 32 hex characters (128 bits). Full SHA-256 is overkill for a cache key that only needs to avoid accidental collisions, not resist deliberate ones from an untrusted party controlling the hash algorithm itself. 128 bits of entropy is effectively collision-free at any realistic request volume, and a shorter key means a shorter, cleaner object path.

That hash becomes the R2 object key: renders/{hash}.png.

Checking the cache: a HEAD request, not a database lookup

src/lib/storage/r2.ts
export async function getCachedRenderUrl(hash: string): Promise<string | null> {
  try {
    await client().send(
      new HeadObjectCommand({ Bucket: bucket, Key: objectKey(hash) })
    );
    return publicUrlFor(objectKey(hash));
  } catch {
    return null;
  }
}

No separate cache index, no Redis, no database table mapping hashes to URLs — the object storage is the cache. A HeadObjectCommand either finds the object or it doesn't, and the object's existence is the only state that needs to be tracked. If it exists, the response is a redirect straight to R2's public URL; if not, that's the render path.

This does mean R2 is on the hot path for every request, including hits — but a HEAD against object storage is fast, and it collapses "is this cached" and "where is it" into a single round trip instead of a separate cache-metadata store that could drift out of sync with what's actually in the bucket.

Honest tradeoff: the catch block treats any HeadObjectCommand failure as a cache miss, not just a genuine 404. A transient R2 network blip looks identical to "this was never rendered." That's a "fail open to a re-render" choice — worst case is a redundant render, which is safe, just not free. It hasn't been worth adding error-type discrimination for.

On a miss: render, then upload with an immutable cache header

src/lib/storage/r2.ts
export async function uploadRender(hash: string, buffer: Buffer): Promise<string> {
  await client().send(
    new PutObjectCommand({
      Bucket: bucket,
      Key: objectKey(hash),
      Body: buffer,
      ContentType: "image/png",
      CacheControl: "public, max-age=31536000, immutable",
    })
  );
  return publicUrlFor(objectKey(hash));
}

max-age=31536000, immutable — cache this for a year, and don't even bother revalidating it. That header would be reckless on a URL like /logo.png that might change underneath you, but it's exactly right for a content-addressed URL: the hash is derived from the content's inputs, so if the inputs ever change, the hash changes, and it's a different URL. The object at a given hash is permanent by construction. Browsers, CDNs, and social-media crawlers' own image caches can all hold onto it indefinitely without a single revalidation request.

The render itself happens in a fairly standard serverless headless-Chromium setup — @sparticuz/chromium + puppeteer-core in production, full puppeteer locally — into a fixed 1200×630 viewport (the de facto standard OG image size):

src/lib/render/browser.ts
const VIEWPORT = { width: 1200, height: 630 };
const RENDER_TIMEOUT_MS = 5000;

Two things worth calling out:

  1. JavaScript is disabled on the rendered page (page.setJavaScriptEnabled(false)). Template inputs like title and author come from untrusted query parameters and get interpolated into HTML — disabling JS means even a successful injection can't execute as script. It's a real constraint (no document.fonts.ready to wait on) but a cheap way to close off a whole class of XSS risk in a system whose entire purpose is turning user-supplied text into rendered pixels.
  2. Two independent 5-second timeout windows — one for content/font loading, one for the screenshot itself — rather than one shared budget. A slow logo fetch and a slow screenshot are different failure modes, and collapsing them into one timeout would make it harder to tell which one actually happened when a render fails.

The browser instance itself is memoized in a module-level promise and reused across warm invocations, since launching Chromium is by far the most expensive part of a cold render.

Cache hits still cost a request

The one place caching doesn't save the caller anything: quota. Both a cache hit and a fresh render call the same usage-recording path, which increments the API key's request count identically either way. That's intentional — quota exists to bound traffic, not compute. A link that goes viral and gets its OG image fetched ten thousand times by crawlers and clients is ten thousand requests against the account regardless of whether R2 served pixels from cache or a browser rendered them fresh; the cache exists to make that cheap and fast for OGImg to serve, not to make it free for the caller to request. Every usage log still records whether it was a cache hit, so the distinction isn't lost — it's just not a billing discount.

Why redirect instead of streaming the bytes

The route never returns image bytes directly — cache hit or miss, it ends in a 302 redirect to the R2 public URL. This keeps the render function itself stateless and short-lived (no need to stream a few-hundred-KB buffer back through the Next.js function on every single request, hit or miss) and hands the actual byte delivery to R2/Cloudflare's edge, which is a better job for a CDN than for a serverless function. It also means a repeat viewer's browser can cache the redirect target directly against that immutable R2 URL on subsequent loads, without OGImg's API being in the path at all.

Numbers, for the curious

Output image size1200 × 630 px
Content-load timeout5000 ms
Screenshot timeout5000 ms (separate budget)
Hash algorithmSHA-256, truncated to 128 bits
R2 cache-controlpublic, max-age=31536000, immutable
R2 object keyrenders/{hash}.png
Redirect status (hit or miss)302

None of this is exotic — it's a fairly standard content-addressed cache pattern applied to image rendering instead of, say, build artifacts. The parts that took more thought than expected were the smaller ones: what exactly goes into the hash, what a cache-check failure should default to, and whether "cached" should mean "free" (it doesn't, on purpose).

OGImg is a dynamic OG image API built on Next.js, Puppeteer, and Cloudflare R2. Learn more →