A cache key is the value a cache computes from a request to decide whether an earlier stored response can answer it: requests with the same key get the same copy, and anything left out of the key is assumed not to matter.
For screenshots, the key must contain every input that changes the pixels. Leave one out and the cache returns the wrong image. Put in one too many and the cache never hits. This entry is part of the screenshot glossary.
What it means in plain terms
A cache is a lookup table. The key is the label on each drawer. Two rules follow:
- Too narrow (an input missing): a 2x retina request gets the 1x image, or a dark-mode request gets the light one.
- Too wide (noise included):
?utm_source=newsletterand?utm_source=xrender the same page twice and pay twice.
request key material ───────────────────────────────────────── ──────────────────────────────── HTTPS://Example.com:443/a?b=2&a=1&utm_source=x#top normalize → https://example.com/a?a=1&b=2 width 1200, height 630 → width, height deviceScaleFactor 2 → scale prefers-color-scheme: dark → colorScheme png → format sha256(stable JSON) → 64 hex characters
How it works technically
Building a good key takes three steps:
- Normalize the URL. Lowercase scheme and host, drop default ports and the
#fragment, sort query parameters, remove tracking parameters you know do not change the page. - Collect every pixel-changing option: viewport, device pixel ratio, colour scheme, locale, timezone, user agent, full page or not, element selector, format and quality, blocked resources, wait conditions, injected CSS.
- Serialize in a stable order and hash. JavaScript object key order follows insertion, so
{a, b}and{b, a}stringify differently. Sort keys first, then hash with SHA-256 to get a fixed-length key.
Leave out anything that does not change the output: the cache TTL, the storage destination, request IDs, the time.
How to build one
This module is shared by the two snippets below. In our test, the same page with a different query order, uppercase host, a fragment and reordered options gave the same key; changing only the scale from 1 to 2 gave a different key.
// cache-key.mjs: one key per distinct image. Same inputs, same key; any visual change, new key. import { createHash } from 'node:crypto'; const DROP_PARAMS = new Set(['utm_source', 'utm_medium', 'utm_campaign', 'fbclid', 'gclid']); export function normalizeUrl(input) { const u = new URL(input); // lowercases scheme and host, drops default ports u.hash = ''; const params = [...u.searchParams].filter(([k]) => !DROP_PARAMS.has(k)); params.sort(([a, x], [b, y]) => a.localeCompare(b) || x.localeCompare(y)); u.search = new URLSearchParams(params).toString(); return u.href; } function canonical(value) { if (Array.isArray(value)) return value.map(canonical); if (value && typeof value === 'object') { return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])])); } return value; } export function cacheKey(url, options) { const material = JSON.stringify(canonical({ url: normalizeUrl(url), ...options })); return createHash('sha256').update(material).digest('hex'); }
Puppeteer
Keep the settings in one object, derive the key from it, and apply the same object to the page. The first run printed miss, rendered, the second hit:
import puppeteer from 'puppeteer'; import { access } from 'node:fs/promises'; import { cacheKey } from './cache-key.mjs'; const url = 'https://example.com/?ref=newsletter'; const settings = { width: 1200, height: 630, deviceScaleFactor: 2, colorScheme: 'dark', format: 'png' }; const file = `${cacheKey(url, settings)}.png`; if (await access(file).then(() => true, () => false)) { console.log('hit', file); } else { const browser = await puppeteer.launch(); try { const page = await browser.newPage(); const { width, height, deviceScaleFactor, colorScheme } = settings; await page.setViewport({ width, height, deviceScaleFactor }); await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: colorScheme }]); await page.goto(url, { waitUntil: 'networkidle0' }); await page.screenshot({ path: file, type: settings.format }); console.log('miss, rendered', file); } finally { await browser.close(); } }
Playwright
In Playwright, emulation lives in the context options, so that object is most of the key material. Add the screenshot options too:
import { chromium } from 'playwright'; import { cacheKey } from './cache-key.mjs'; const url = 'https://example.com/'; const contextOptions = { viewport: { width: 1200, height: 630 }, deviceScaleFactor: 2, colorScheme: 'dark', locale: 'en-GB', }; const key = cacheKey(url, { ...contextOptions, fullPage: false, type: 'png' }); const browser = await chromium.launch(); try { const context = await browser.newContext(contextOptions); const page = await context.newPage(); await page.goto(url); await page.screenshot({ path: `${key}.png`, type: 'png' }); console.log(key); } finally { await browser.close(); }
Tested on 2026-10-15 with Node 22.18.0, [email protected] (run with headless: 'shell') and [email protected].
RenderScreenshot
The API builds the key for you. It is a SHA-256 hash of the rendering options after preset and device defaults are filled in, scoped to your account. The cache and storage groups are left out, so changing the TTL does not lose earlier renders. Two details from the caching reference matter:
- The target URL is used as sent, so a different query string is a different key. Normalize it on your side with a function like
normalizeUrlabove. - The preset name is part of the key.
preset=og_cardand the same size spelled out do not share an entry. Pick one style and keep it.
Lookups happen only when you ask, with cache_available=true:
curl -G https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ --data-urlencode "url=https://example.com/a?a=1&b=2" \ -d preset=og_card -d cache_available=true \ -D headers.txt --output card.png
A reuse returns X-Cache-Hit: true and X-Credits-Used: 0. The response also carries X-Cache-Key, which is the storage key of that render (a path such as screenshots/ab/cd/...png), not the lookup hash. You pass it to the cache management endpoints. This request was not run for this page.
Common mistakes
- Raw URLs as keys. Tracking parameters and query order split one page into many entries.
- Forgetting the scale or colour scheme. The most common cause of a wrong image served from cache.
JSON.stringifywithout sorting. Two equal option objects give two keys.- Including the TTL or a timestamp. The key changes every time and nothing is reused.
- Sharing keys across tenants. Two customers' "same URL" may show different content behind auth. Add the tenant to the key, as the API does with the account.
Related terms
- Cache invalidation: changing the key is the fastest way to invalidate.
- Cache TTL: how long an entry under a key stays fresh.
- Device pixel ratio and prefers-color-scheme: two inputs that must be in the key.
To see cache hits for 0 credits on repeat requests, create a free account and send the request above twice.