Cache Key: What Makes Two Requests the Same

A cache key is the value a cache uses to decide two requests share one stored response. How to build one for screenshots, with tested normalization code.

By Simon Chiu · Updated

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=newsletter and ?utm_source=x render 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:

  1. 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.
  2. 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.
  3. 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 normalizeUrl above.
  • The preset name is part of the key. preset=og_card and 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.stringify without 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.

To see cache hits for 0 credits on repeat requests, create a free account and send the request above twice.

Frequently asked questions

Should the cache key include the full URL?
It should include a normalized URL: lowercase host, sorted query parameters, no fragment, and no tracking parameters such as utm_source. Otherwise the same page gets many keys and the cache rarely hits.
Why did two identical screenshot requests not share a cache entry?
Something in the key differed: a query parameter order, a trailing slash, a different scale or format, or a preset name in one request and the same values spelled out in the other.
Is X-Cache-Key the same as the cache key?
Not exactly. RenderScreenshot looks up earlier renders by a SHA-256 hash of the rendering options. X-Cache-Key is the storage key of one stored render, the handle you pass to the purge endpoints.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.