Back to blog
CachingScreenshot APIPerformance

Caching Screenshot API Responses Without Breaking Freshness

Learn how to cache screenshot API responses with smart TTLs, hash-based keys, CDN layers, and stale-while-revalidate to cut costs and latency.

2026-06-016 min read

Screenshot APIs are deceptively expensive. Each request spins up a headless browser, loads a page, waits for fonts and JavaScript, and renders pixels. If you're hitting the API on every page load — for OG images, link previews, or PDF generation — you're burning latency and money on captures that haven't changed.

The fix is caching. But screenshot responses don't cache like JSON. They're large binary blobs, often tied to dynamic URLs, and they go stale at unpredictable rates. Here's how to build a caching layer that actually works.

Pick the right cache key

A naive cache keyed on URL alone will return wrong images the moment you change viewport size or format. Every parameter that affects the output needs to be in the key.

Parameters to hash

  • Target URL (normalized — lowercase host, sorted query params)
  • Viewport width and height
  • Device scale factor (1x vs 2x)
  • Format (png, jpeg, webp, pdf)
  • Full page vs viewport
  • Wait conditions (selector, delay, networkidle)
  • Custom CSS or JS injection

Hash these into a stable key with SHA-256:

import crypto from 'crypto';

function cacheKey(params) {
  const normalized = {
    url: params.url.toLowerCase().trim(),
    width: params.width || 1280,
    height: params.height || 720,
    format: params.format || 'png',
    fullPage: !!params.fullPage,
    scale: params.scale || 1,
  };
  const json = JSON.stringify(normalized, Object.keys(normalized).sort());
  return crypto.createHash('sha256').update(json).digest('hex');
}

Store the resulting hash as the filename or object key. Now example.com?utm=foo and example.com?utm=bar can either collide or stay separate depending on whether you strip tracking params before hashing.

Choose a TTL that matches the content

One global TTL is wrong for every use case. Match the TTL to how fast the source changes.

  1. OG images for blog posts — 7 to 30 days. Post content rarely changes after publish.
  2. Link previews for user-submitted URLs — 24 to 72 hours. Balance freshness with hit rate.
  3. Dashboard or monitoring screenshots — 1 to 5 minutes, or no cache at all.
  4. Marketing site PDFs — 1 to 7 days, with manual purge on deploy.
  5. E-commerce product cards — 1 to 6 hours. Prices and stock shift.

If you're using PxShot for OG images on a blog, a 30-day TTL with an explicit purge on post update gives you near-100% cache hit rate without ever serving stale thumbnails.

Layer the cache

A single cache tier is a single point of failure. Stack them so each layer absorbs traffic the one below couldn't.

Layer 1: CDN edge cache

Put Cloudflare, Fastly, or CloudFront in front of your screenshot endpoint. Return proper headers from your origin:

Cache-Control: public, max-age=86400, s-maxage=604800, stale-while-revalidate=3600
ETag: "sha256-hash-here"

The s-maxage tells the CDN to cache for a week even if browsers only hold it for a day. stale-while-revalidate lets the CDN serve a slightly old image while it fetches a fresh one in the background — critical for avoiding visible latency on cache misses.

Layer 2: Object storage

Store generated screenshots in S3, R2, or B2 keyed by the hash. On request:

  1. Compute the cache key from request params
  2. Check if cache/{hash}.png exists in the bucket
  3. If yes, redirect (302) or stream it back
  4. If no, call the screenshot API, save the result, then serve it

Cloudflare R2 with no egress fees pairs especially well here — you can serve unlimited screenshots from cache without bandwidth bills.

Layer 3: In-memory for hot keys

For high-traffic pages (your homepage OG image, popular product cards), keep a Redis or in-process LRU of the last few hundred most-requested hashes. This avoids even the S3 round-trip.

Handle cache misses gracefully

The first request for any URL will miss. If that's a user waiting on a page load, you've made things worse, not better.

Pre-warm on content events

Trigger screenshot generation when content is created or updated, not when it's first requested:

  • New blog post published → enqueue a job to generate the OG image
  • User submits a link → fetch the preview in a background worker
  • Product updated → invalidate old cache, generate new screenshot

By the time anyone requests the image, it's already in S3.

Serve a placeholder during generation

For unavoidable on-demand captures, return a lightweight placeholder (a blurhash, a default image, or a 1x1 transparent PNG) immediately and generate the real screenshot in the background. Update the cache once ready.

Invalidation strategies

Caches that never expire become liabilities. Build invalidation in from day one.

Content-hash invalidation

Include a content version or last-modified timestamp in the cache key:

const key = sha256(url + width + height + post.updatedAt);

When the post updates, updatedAt changes, the key changes, and you fetch fresh automatically. The old cached object eventually ages out.

Tag-based purging

Maintain a reverse index: url → [list of cache keys]. When a URL needs invalidation, delete every key in the list. Cloudflare's cache tags feature does this natively if you're on a Business plan.

Manual purge endpoints

Expose an admin endpoint that takes a URL and clears all variants. Useful for ops emergencies when something rendered wrong.

Avoid these common mistakes

  • Caching error responses. If the screenshot API returns a 500 or a blank page, don't cache it. Check status and image dimensions before storing.
  • Ignoring viewport in the key. Mobile and desktop OG images will collide and one will overwrite the other.
  • Forever TTLs without purge. You will need to invalidate something. Build the mechanism before you need it.
  • Caching authenticated pages by URL alone. If a screenshot includes user-specific content, namespace the cache by user ID or skip the cache entirely.
  • Not compressing. Store WebP in cache even if clients request PNG — convert on the fly. Cache storage drops by 50–70%.

Measure what your cache is actually doing

Track these metrics from day one:

  • Hit rate per layer (CDN, object storage, memory)
  • P95 latency on hit vs miss
  • Cost per 1000 requests — should drop as hit rate climbs
  • Stale-serve rate — how often stale-while-revalidate fires

A healthy setup for OG images should see 95%+ CDN hit rate and sub-50ms P95 latency. If you're seeing 30% hit rates, your cache key is too specific or your TTLs are too short.

PxShot's API returns clean ETag and Cache-Control headers out of the box, so it slots into a CDN-fronted setup without extra work. If you want to test a caching layer with real screenshots, the free tier at pxshot.dev gives you enough monthly captures to prototype the whole pipeline.