Cache-Control Header: Caching Rules for Images

Cache-Control is the HTTP header that tells browsers and CDNs how long to keep a response. The directives that matter for screenshot images, with tested code.

By Simon Chiu · Updated

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

  • immutable on a URL that changes content. Browsers will not ask again until max-age ends. Use it only with versioned URLs.
  • public on per-user screenshots. A CDN may serve one user's dashboard image to another. Use private or no-store.
  • Confusing no-cache with no-store. Only no-store keeps the file off disk.
  • Forgetting the ETag. Short max-age without a validator means a full download on every revalidation.
  • Long max-age on 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.

To get a cached screenshot link you can serve directly, create a free account and read X-Cache-URL from the first response.

Frequently asked questions

What is the difference between no-cache and no-store?
no-cache lets a cache keep the response but forces it to check with the server before each reuse. no-store forbids keeping it at all. For a private screenshot that must never sit on disk, use no-store.
What Cache-Control should a generated OG image have?
If the image URL changes when the content changes, use public, max-age=31536000, immutable. If the URL stays the same, use a shorter max-age with an ETag, so caches can revalidate cheaply.
Does sending Cache-Control: no-cache in a request bypass a CDN?
Not always. It is a request to the cache, and many CDNs ignore it by default. In our test, a Cloudflare-served page still answered HIT. Purge or change the URL instead.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.