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/ogadds cache headers — but only if you deploy with them; the default path doesn't force them.
The Fix
Three lines of defense, cheapest first:
- Cache by slug, not by request. One render per piece of content, invalidate on publish only.
- 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;
}
- 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)