How to Cache Screenshot API Responses
Design cache keys, TTLs, CDN headers, refresh paths, and privacy controls for reliable screenshot API caching.
Cache screenshot responses by the complete rendering request, not by URL alone. Include every option that can change pixels, choose a TTL based on how quickly the page changes, keep private renders out of shared caches, and provide an explicit fresh-capture path. Store durable copies yourself when you need retention beyond a provider’s cache.
1. Decide what “the same screenshot” means
A cache hit is correct only when the target page and every rendering input are equivalent. Build a canonical request object, serialize it deterministically, and hash it into a key.
| Input group | Include in the key |
|---|---|
| Target | Normalized URL, including meaningful query parameters and fragment handling |
| Viewport | Width, height, full-page mode, device preset, device scale factor |
| Output | PNG, JPEG, WebP, quality, resizing, transparent background, PDF paper size, margins, orientation and page range |
| Page state | Dark mode, locale, timezone, geolocation, user agent |
| Identity | Tenant, cookies, Authorization context and other private headers |
| Rendering script | Injected CSS, JavaScript, clicked element, hidden selectors, selected element |
| Timing | Wait-for selector, delay, network-idle rule |
| Network policy | Blocked ads, trackers, requests and resource types |
Changing any of these can change pixels. ScreenshotEngine documents that capture options create different cache keys and that GET and POST requests are not guaranteed to share an entry. Treat the HTTP method as part of your provider integration unless its documentation explicitly guarantees method-independent caching.
Canonical key example
import crypto from "node:crypto";
function canonicalize(input) {
return JSON.stringify({
url: new URL(input.url).toString(),
viewport: {
width: input.viewport?.width ?? 1366,
height: input.viewport?.height ?? 768
},
format: input.format ?? "webp",
deviceScaleFactor: input.deviceScaleFactor ?? 1,
fullPage: Boolean(input.fullPage),
locale: input.locale ?? "en-US",
timezone: input.timezone ?? "UTC",
selector: input.selector ?? null,
css: input.css ?? null,
javascript: input.javascript ?? null,
waitFor: input.waitFor ?? null,
tenant: input.tenant,
authContext: input.authContextHash ?? null
});
}
export function cacheKey(input) {
const canonical = canonicalize(input);
return "screenshot:v1:" + crypto.createHash("sha256").update(canonical).digest("hex");
}
Hash a stable representation of authentication context; never put raw tokens or cookies in a key, URL, log, or CDN path.
2. Choose a TTL deliberately
TTL is a product decision: balance visual staleness, render cost, invalidation effort, privacy and storage cost.
| Page type | Starting policy |
|---|---|
| Live dashboard, stock or status page | Minutes, with an explicit refresh option |
| Marketing or documentation page | Hours to days, refreshed on deployment |
| Versioned release artifact | Immutable key or URL; retain indefinitely in object storage |
| Personalized or authenticated page | Private cache or no-store unless the full identity context is isolated |
Provider settings are vendor-specific. Screenshot API documents cache=true, cacheTTL in seconds (default 86,400), and staleTTL for serving stale content while refreshing. ScreenshotOne documents a four-hour default and cache_ttl up to one month. These are examples, not universal defaults.
3. Add a durable cache in front of the screenshot provider
- Normalize the URL and options.
- Hash the canonical request, including tenant and identity context for private images.
- Read object storage or another durable cache first when retention matters.
- On a miss, call the screenshot API with the selected provider cache settings.
- Write the bytes with the correct
Content-Type, length, an ETag and an immutable or versioned object name. - Return HTTP cache headers that match the screenshot’s privacy and freshness.
import hashlib
import json
from urllib.parse import urlsplit, urlunsplit
def normalize_url(value: str) -> str:
parts = urlsplit(value)
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path or "/", parts.query, ""))
def make_key(request: dict) -> str:
canonical = {
"url": normalize_url(request["url"]),
"viewport": request.get("viewport", {"width": 1366, "height": 768}),
"format": request.get("format", "webp"),
"device_scale_factor": request.get("device_scale_factor", 1),
"full_page": request.get("full_page", False),
"locale": request.get("locale", "en-US"),
"timezone": request.get("timezone", "UTC"),
"selector": request.get("selector"),
"css": request.get("css"),
"javascript": request.get("javascript"),
"wait_for": request.get("wait_for"),
"tenant": request["tenant"],
"auth_context_hash": request.get("auth_context_hash")
}
encoded = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode()
return "screenshot:v1:" + hashlib.sha256(encoded).hexdigest()
Use a short-lived lock per key to prevent a thundering herd when many requests miss simultaneously. Let one request render while others wait briefly or receive a stale object.
4. Put public screenshots behind HTTP and CDN caching
For a public, non-personalized image, return a stable URL and shared-cache headers:
Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=60
ETag: "sha256-<rendered-bytes-hash>"
Content-Type: image/webp
Content-Length: <byte-count>
Use private, max-age=0 or no-store for user-specific screenshots. Cloud CDN documentation lists Set-Cookie, Cache-Control: no-store or private, request no-store, unsuitable Vary values and many authenticated requests as conditions that can prevent shared caching. Do not allow an authenticated render to populate a public key.
For origin responses larger than 1 MiB, Google Media CDN requires a validator such as Last-Modified or ETag, plus valid Date and Content-Length, before caching the response.
5. Support stale-while-revalidate and explicit refresh
Stale-while-revalidate keeps latency predictable: serve a still-acceptable object, then refresh it asynchronously. Keep a separate hard expiration so a permanently failing origin cannot serve stale data forever.
// Pseudocode for a cache-aside request with refresh protection
async function getScreenshot(request) {
const key = cacheKey(request);
const cached = await objectStore.get(key);
if (cached && cached.expiresAt > Date.now()) return cached;
if (cached && cached.staleUntil > Date.now()) {
queueRefreshOnce(key, request);
return cached;
}
return renderUnderLock(key, request);
}
async function forceRefresh(request) {
const key = cacheKey({ ...request, refreshVersion: crypto.randomUUID() });
return renderUnderLock(key, request, { bypassProviderCache: true });
}
ScreenshotEngine supports POST cachePolicy: "no-cache" to bypass lookup and storage and reports X-Cache: HIT, MISS or BYPASS. If your provider uses another parameter, follow its documented fresh-capture option or add a version component to your own key. Log the normalized key, TTL, cache result, render duration and source-page version.
6. cURL example: cache your own response
KEY="screenshot:v1:$(printf '%s' 'canonical-request-json' | sha256sum | cut -d' ' -f1)"
# 1. Look up $KEY in Redis or object storage.
# 2. On a miss, request the image and save it durably.
curl -G "https://api.example.com/screenshot" \
--data-urlencode "url=https://example.com" \
--data "cache=true" \
--data "cacheTTL=3600" \
-o screenshot.webp
# Force a fresh request when the provider supports it:
curl -X POST "https://api.example.com/screenshot" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com","cachePolicy":"no-cache"}' \
-o fresh.webp
Replace provider-specific parameters with the exact options documented by your service. Do not assume that a cache hit is free: ScreenshotEngine states that successful screenshot requests, including cache hits, count toward monthly usage.
7. Avoid common cache failures
| Symptom | Cause | Fix |
|---|---|---|
| Wrong viewport or theme appears | Key contains only the URL | Include viewport, device scale, color scheme and every pixel-changing option |
| POST misses after a GET hit | Provider does not share method entries | Use one method consistently or verify method-sharing behavior |
| Private image is publicly visible | Identity was omitted from key or CDN policy | Partition by tenant and auth context; use private/no-store headers |
| Cache disappears unexpectedly | Provider cache is ephemeral | Persist returned bytes in object storage; provider documentation notes entries may disappear after an instance restart |
| CDN never hits | Set-Cookie, private, no-store or unsuitable Vary |
Remove those headers for public images and set an explicit shared-cache policy |
| Origin overload during expiry | Many callers refresh the same key | Use a distributed lock, request coalescing and stale-while-revalidate |
| Refresh still returns old pixels | Only your cache was bypassed | Also bypass the provider cache or change the provider key/version |
8. Performance, reliability and cost checklist
- Measure hit ratio, byte size, render time, provider cache result and stale responses.
- Keep cache keys compact and hash them; store the canonical request separately for debugging.
- Compress WebP or JPEG when lossless PNG is unnecessary.
- Use immutable URLs for release screenshots so CDN invalidation is unnecessary.
- Set bounded connection, render and queue timeouts.
- Retry only transient failures, with exponential backoff and a cap; do not retry bot checks or invalid URLs indefinitely.
- Count provider cache hits according to the provider’s billing rules.
- Budget object-storage, CDN egress and invalidation costs alongside render charges.
- Keep credentials out of URLs, cache keys, logs and public object metadata.
9. Or skip the browser setup
ScreenshotNeo provides a GET endpoint that returns PNG, JPEG, WebP or PDF. Its cache supports a TTL you choose, and you can still save the returned bytes in your own durable cache for retention or CDN delivery. See the ScreenshotNeo API documentation for request options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, popups and chat widgets are removed before the shot. Bot checks, blank pages and failed loads are never billed, and response headers identify the page verdict and billing status. An MCP server lets AI agents take screenshots. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.
Create your free ScreenshotNeo account.
10. FAQ
How long should a screenshot stay cached?
Set the shortest TTL that meets your freshness requirement. Minutes suit frequently changing dashboards; hours or days suit stable documentation. Personalized pages need private isolation regardless of TTL.
Can I cache a screenshot API behind a CDN?
Yes, for public, non-personalized outputs with explicit shared-cache headers, validators and a stable URL. Use private or no-store for confidential renders.
Should I store screenshots permanently in the provider cache?
No. Provider caches are optimization layers. Save returned files in object storage when you need durable retention, auditability or high-volume reads.
How do I force a fresh screenshot?
Use the provider’s documented cache-bypass option, such as a no-cache policy, and replace your stored object only after a successful render.


