Cache-Control is the HTTP header that tells browsers, proxies and CDNs whether they may store a response, for how long, and when they must check back with the server.
For screenshot images it decides two things: how often your server re-sends the same PNG, and how long a stale image can survive after the page changes. This entry is part of the screenshot glossary.
What it means in plain terms
Every image response can carry instructions for the caches along its way. max-age=3600 means "reuse this for an hour without asking". no-store means "do not keep this at all". Without the header, caches guess, and different caches guess differently.
The directives that matter for images
| Directive | Applies to | Effect on a screenshot image |
|---|---|---|
max-age=N |
all caches | Fresh for N seconds; reused without a request |
s-maxage=N |
shared caches (CDN, proxy) | Overrides max-age for CDNs only |
public |
all caches | May be stored by shared caches, even on authenticated requests |
private |
browser only | CDNs must not store it; use for per-user screenshots |
no-cache |
all caches | May store, but must revalidate before every reuse |
no-store |
all caches | Must not store at all |
immutable |
browsers | Do not revalidate while fresh, even on reload |
stale-while-revalidate=N |
all caches | Serve the stale copy for N seconds while refetching in the background |
How a response moves through them:
origin ──Cache-Control: public, max-age=3600, s-maxage=86400──▶ CDN edge keeps it 1 day CDN edge ─────────────────────────────────────────────────────▶ browser keeps it 1 hour after 1 hour: browser sends If-None-Match: "<etag>" edge answers 304 Not Modified (no image bytes) or 200 with a new image
How it works technically
A cache computes freshness as max-age (or s-maxage for shared caches) minus the Age header. While fresh, it serves the stored copy. Once stale, it sends a conditional request with the ETag or Last-Modified value, and the server answers 304 Not Modified when nothing changed. That is why an ETag makes short max-age values cheap.
Without Cache-Control or Expires, caches use a heuristic, often a fraction of the time since Last-Modified. When we inspected https://example.com on 2026-10-15, the page and its script had no Cache-Control at all, yet the CDN in front answered with an Age header of almost two hours. Absent rules do not mean absent caching.
How to work with it
Puppeteer: read the headers and turn the cache off
During a capture, the browser's own cache obeys these headers. Turn it off when you capture the same page repeatedly and need fresh assets each time:
import puppeteer from 'puppeteer'; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setCacheEnabled(false); // every reload goes to the network page.on('response', (res) => { const cc = res.headers()['cache-control'] ?? '(none)'; console.log(res.status(), res.request().resourceType().padEnd(10), cc, res.url()); }); await page.goto('https://example.com/', { waitUntil: 'networkidle0' }); await page.screenshot({ path: 'cc-puppeteer.png' }); } finally { await browser.close(); }
Playwright: the same through a CDP session
Playwright has no setCacheEnabled. In Chromium, a CDP session does the job:
import { chromium } from 'playwright'; const browser = await chromium.launch(); try { const context = await browser.newContext(); const page = await context.newPage(); const cdp = await context.newCDPSession(page); await cdp.send('Network.enable'); await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); const response = await page.goto('https://example.com/'); const headers = await response.allHeaders(); console.log({ 'cache-control': headers['cache-control'], age: headers['age'], expires: headers['expires'] }); await page.screenshot({ path: 'cc-playwright.png' }); } finally { await browser.close(); }
Serving the screenshot you made
The header matters most when you serve the image. This Node server sends caching rules and an ETag; a repeat request with If-None-Match got 304 in our test:
import http from 'node:http'; import { readFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; http.createServer(async (req, res) => { try { if (req.url !== '/og/home.png') { res.writeHead(404).end(); return; } const body = await readFile('cc-puppeteer.png'); const etag = `"${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`; if (req.headers['if-none-match'] === etag) { res.writeHead(304, { etag }).end(); return; } res.writeHead(200, { 'content-type': 'image/png', // Browsers keep it 1 hour, shared caches 1 day, then serve stale for 1 more hour while refetching. 'cache-control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=3600', etag, }); res.end(body); } catch (err) { console.error(err); res.writeHead(500).end(); } }).listen(4103);
Tested on 2026-10-15 with Node 22.18.0, [email protected] (run with headless: 'shell'), [email protected] and curl.
RenderScreenshot
The API has its own cache, set by cache.ttl rather than by Cache-Control. The stored render is reachable at the public X-Cache-URL until X-Cache-Expires. The caching reference covers the TTL range and reuse.
If the page you capture sits behind its own cache, you can send request headers to it with network.headers:
{ "url": "https://example.com/pricing", "network": { "headers": { "Cache-Control": "no-cache" } } }
Treat that as a hint. In our Playwright test, a request with Cache-Control: no-cache to a Cloudflare-served page still came back cf-cache-status: HIT. To force a fresh page, purge the site's CDN or add a version parameter to the URL. This JSON was not sent to the API for this page.
Common mistakes
immutableon a URL that changes content. Browsers will not ask again untilmax-ageends. Use it only with versioned URLs.publicon per-user screenshots. A CDN may serve one user's dashboard image to another. Useprivateorno-store.- Confusing
no-cachewithno-store. Onlyno-storekeeps the file off disk. - Forgetting the ETag. Short
max-agewithout a validator means a full download on every revalidation. - Long
max-ageon a link that expires. If the URL stops working before the cache lets go, some viewers see the image and others get an error. Match it to the cache TTL.
Related terms
- Cache TTL: how long a cached copy counts as fresh.
- ETag: the validator behind
304 Not Modified. - CDN edge cache: the shared cache
s-maxagecontrols. - Cache invalidation: what to do when the rules were too long.
To get a cached screenshot link you can serve directly, create a free account and read X-Cache-URL from the first response.