← All posts

A Screenshot API Caching Strategy That Won't Serve Stale UI

September 23, 2026

Why Screenshot and PDF Caching Is Different From Normal Web Caching

Caching an HTML page or a JPEG is cheap to get wrong. Caching a screenshot or PDF is not, because every uncached request is a full headless browser execution — navigating, waiting for network idle or a selector, painting, and encoding an image or document. That's CPU, memory, and seconds of latency, not a disk read. Too loose, and render cost climbs linearly with traffic even when nothing has changed. Too aggressive, and you ship stale screenshots that mislead people.

Picture a pricing page screenshot in a competitor-monitoring dashboard. The vendor drops their price at 9am; your cache holds the old render until midnight because the TTL was set to "24 hours" out of habit. Someone decides based on a wrong number. Or a link preview: a marketing team updates a product's OG image, but Slack, Twitter, and internal tools keep unfurling the old screenshot for days because nobody built an invalidation path — a scenario covered in this look at broken OG:image unfurling. Neither failure is a caching bug in the traditional sense — they're the predictable result of applying static-asset caching logic to a source that mutates on its own schedule. A real screenshot API caching strategy treats render cost and staleness risk as two variables tuned independently, not one problem solved with a single max-age value.

Designing a Cache Key That Actually Matches Your Requests

The most common cause of both wasted renders and wrong images is a cache key that's too coarse. Key purely on the target URL and you'll get false hits: a request for a 1280×800 desktop screenshot returns a cached 375×667 mobile render, or a PDF request returns a cached PNG. Key on too little context and you risk false misses — treating identical requests as different and paying for a duplicate render.

Good cache key design for a rendering API should incorporate:

  • Target URL, normalized (strip tracking params like utm_* unless they change page content).
  • Viewport and device — width, height, device scale factor, and any device-emulation preset. A screenshot at 320px and one at 1920px are not interchangeable.
  • Output mode — full-page vs. clipped region, and the exact clip coordinates if used.
  • Format — PNG, JPEG, PDF, and PDF-specific options like paper size or print background.
  • Injected cookies, auth headers, or query params that change what's rendered (logged-in vs. logged-out).
  • Wait conditionswaitUntil network-idle vs. a specific selector, since these can produce different DOM states for the same URL.

Any parameter that can change pixel output belongs in the key. Any parameter that can't (like an API token used only for billing) should be excluded, or you'll fragment your cache and pay for renders you already have.

Setting TTLs by Content Volatility, Not by Habit

Once the key is right, the next lever is TTL. The mistake most teams make is picking one number for everything. Instead, tier max-age by how often the underlying page actually changes:

  • Static marketing pages, docs, landing pages: Cache-Control: max-age=86400 (24 hours) or longer.
  • Product/price/stock pages: max-age=300 to max-age=900 (5–15 minutes) — frequent enough to catch price drops without re-rendering on every request.
  • Dashboards, real-time data, live charts: max-age=30 to max-age=60, or skip caching for authenticated/personalized views entirely.

This is standard Cache-Control syntax, but the discipline is mapping TTL to actual observed change frequency rather than a company-wide default. If you don't know how often a page changes, start with a short TTL and lengthen it once you've confirmed renders come back identical.

Using Stale-While-Revalidate to Hide Render Latency

stale-while-revalidate, defined in RFC 5861, lets a cache serve an expired-but-recent response immediately while fetching a fresh one in the background. For a rendering API this is disproportionately valuable because the "fetch" isn't a fast origin round-trip — it's a multi-second browser render. Without SWR, every cache expiry means the next request pays full render latency. With Cache-Control: max-age=300, stale-while-revalidate=1800, a request arriving 20 minutes after expiry still gets an instant response (the stale image), while a background revalidation refreshes the cache for subsequent requests.

The tradeoff is honest: within that revalidation window, some users see a screenshot that's stale but recent, not stale but ancient. That's usually right for latency-sensitive use cases like link unfurling, and wrong for a compliance screenshot that must reflect exact current state — know which category your use case falls into before enabling it.

Forcing Fresh Renders: Invalidation and Bypass Patterns

TTLs and SWR handle the general case; you also need a way to force a fresh render the moment content changes — after a deploy, a price update, or a CMS publish. Useful patterns, several overlapping with standard CDN cache invalidation strategies:

  • Cache-busting query params appended to the target URL on known-change events, generating a new cache key deliberately.
  • Explicit purge or force-refresh API calls — a dedicated endpoint or a forceFresh parameter that bypasses cache for one request and repopulates it.
  • Webhook-triggered purges tied to source events (CMS publish, price-update webhook) instead of relying on someone remembering to do it.
  • Tag-based purging for related URLs — invalidate every screenshot tied to a product ID or domain in one call rather than tracking individual cache keys.
  • ETag / If-None-Match validation for cases where the client should confirm freshness cheaply before accepting a cached body.

Where to Put the Cache: Client, CDN, or the Rendering API Itself

You have three real options for where caching lives, and they're not mutually exclusive.

Client-side (your app) gives full control and zero third-party dependency, but you own building the cache key logic, TTL rules, and invalidation hooks from scratch — and you still pay full render cost on every cache miss from your own infrastructure.

CDN edge caching (Varnish, Cloudflare, etc.) in front of a render API is cheap and fast for globally distributed read traffic, and handles stale-while-revalidate well natively. Its weakness: CDNs cache by request signature, so if your cache key design isn't reflected accurately in the URL or headers you send, you inherit the false-hit/false-miss problems above at the network layer, which is harder to debug.

Caching built into the render API means the service understands render parameters natively — viewport, format, wait conditions — and can key, tier TTLs, and expose forced-refresh without you reimplementing render-aware cache logic on top of a generic CDN. This is where Browsevra sits: render and caching controls are exposed directly in the API rather than left for you to bolt on, so teams don't have to build a separate caching layer in front of a raw headless browser fleet just to avoid burning render minutes. You can see the actual cache and render parameters in the Browsevra docs, and check how cache hits translate to cost on the pricing page. Pairing this with a visual monitoring layer, like the pattern in screenshot-based uptime monitoring, lets you catch unexpected content drift even when your cache is behaving correctly.

Building your own render-aware cache in front of a browser fleet is legitimate if you have very specific requirements, but it's also ongoing maintenance work most teams don't need to own. If you'd rather not build cache key logic, TTL tiers, and purge webhooks from scratch, browsevra gives you those primitives directly in the render API.

Frequently Asked Questions

Should I cache screenshots by full URL or by a normalized cache key?

Use a normalized cache key, not the raw URL. A key should combine the normalized URL with viewport, format, clip settings, and any cookies or headers that affect rendered output — using the URL alone causes false hits across different viewports or formats and false misses from irrelevant query params.

How long should I cache a PDF generated from a web page?

It depends entirely on how often the source page changes: static documents can use max-age of a day or more, while price or inventory-driven PDFs should use 5–15 minutes, and anything reflecting real-time data should use very short TTLs or bypass caching. Tier your TTL by content volatility rather than applying one default across all PDF endpoints.

Does stale-while-revalidate work the same way for an API response as it does for a webpage?

The core mechanic is identical — serve the stale cached response instantly while revalidating in the background — but the stakes differ because a render API's "revalidation" is a full headless browser execution, not a fast origin fetch. That makes SWR especially valuable for hiding render latency, with the tradeoff that some requests during the window see a stale-but-recent image.

How do I invalidate a cached screenshot the moment I know the page changed?

Use an explicit purge or force-refresh call tied to the specific cache key, ideally triggered automatically by a webhook from your CMS, pricing system, or deploy pipeline. Tag-based purging is useful when one content change affects multiple related screenshot URLs at once.

Is it cheaper to cache at the CDN layer or inside the rendering API?

Caching inside the rendering API is usually more cost-effective because it understands render-specific parameters (viewport, format, wait conditions) natively, avoiding the cache key mismatches that happen when a generic CDN caches by request signature alone. A CDN edge in front of a render API still adds latency and global-distribution benefits, and the two can be combined.

Can caching cause me to serve the wrong screenshot to the wrong user?

Yes — if the cache key omits a parameter that changes rendered output, like viewport, injected cookies, or auth state, you can serve one user's personalized or device-specific render to another. This is exactly why cache key design should include every render parameter that affects pixels, not just the target URL.