DEV Community

Cover image for Cache the /og-image before it costs you $160 a month
xxxn3m3s1sxxx
xxxn3m3s1sxxx

Posted on

Cache the /og-image before it costs you $160 a month

Cache the /og-image before it costs you $160 a month

A single uncached /og-image route turned a $0.15/GB screenshot bill into $160/month of overage in under twelve days.

Not a hypothetical. A screenshot service running on Vercel Pro burned 494 GB-hours of serverless compute in 12 days. Extrapolated across the billing cycle, that's ~1,276 GB-hours a month — about $160/month of overage on top of the $20 seat, because the function rounds up to the nearest 50ms and bills memory for the whole lifetime of a cold start.

Honeymoon

You build a dynamic Open Graph image. One route, JSX in, PNG out. Free on Hobby. It only runs when someone shares a link, right? Wrong. Every crawler that touches a page triggers it. LinkedIn, Twitter, Slack, iMessage, WhatsApp, Discord. Each one re-fetches on a different schedule. Your "one render per post" is actually five to eight renders per post per platform, and nobody cached it.

The Cracks

The function has two failure modes that compound:

  • It's an implicit DDoS response surface. Un-cached, every ?slug= variant is a full recompute. A scraper or preview crawler enumerating posts drives compute linearly.
  • Auth sits before the cache. Most setups re-validate ownership per request instead of letting an edge cache absorb the burst. @vercel/og adds cache headers — but only if you deploy with them; the default path doesn't force them.

The Fix

Three lines of defense, cheapest first:

  1. Cache by slug, not by request. One render per piece of content, invalidate on publish only.
  2. Set explicit cache headers so the edge serves the PNG, not your function:
// app/api/og/route.tsx - cache the RESULT, not the compute
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export async function GET(req: Request) {
  const slug = new URL(req.url).searchParams.get('slug') ?? 'default';

  const img = new ImageResponse(
    <div style={{ width: 1200, height: 630, display: 'flex',
                 alignItems: 'center', justifyContent: 'center',
                 fontSize: 64, background: '#0b0d10', color: '#fff' }}>
      {slug}
    </div>
  );

  img.headers.set('Cache-Control',
    'public, max-age=31536000, s-maxage=86400, stale-while-revalidate=604800');
  return img;
}
Enter fullscreen mode Exit fullscreen mode
  1. Put a budget tripwire on the account. Vercel's Spend Management exists but defaults to alerts only — it does not stop traffic unless you configure a hard cap. Set the cap to something painful-but-survivable so the invoice never gets to pick the number.

TACTICAL DEBRIEF

OG images are the canonical "cheap until it isn't" route: low traffic, high recompute cost, triggered by third parties you don't control. Treat every cacheable endpoint like an attack surface first. If the platform bills by compute-seconds and the crawler controls demand, your only lever is the cache header. Cache by content identity (the slug), not by request, and add the spend cap before the feature ships — not after the invoice lands.

One route, un-cached, is the difference between a $0 SaaS and an invoice.


Sources

  • Zach Leatherman — The real costs of serverless (the 494 GB-hour screenshot service, $16↔0/mo projection): zachleat.com/web/serverless-cost
  • The Vercel Upsell Game — An investigation (screenshot workload, DDoS-you-pay-for, $0.15/GB overage): theupsellgame.com
  • Dynamic OG images for Next.js & Vercel (3 production patterns) — cache by slug: dev.to/snapshotflow

We document the real failure modes of the serverless pipeline — bills, caches, and agent workflows — on our YouTube channel. Link in bio.

Top comments (0)