Cache invalidation is the act of making a cache stop serving a stored copy once the original has changed, by deleting the copy (purge), letting it expire (TTL), or moving readers to a new address (versioning).
It is famous for being hard because a copy can live in several caches you do not control. For screenshots the stakes are visible: an outdated social card or a dashboard image that shows last week's numbers. This entry is part of the screenshot glossary.
What it means in plain terms
A cache is a bet that the original will not change soon. Invalidation is what you do when you lose the bet. You have three moves, and each has a cost:
| Strategy | How it works | Speed | Reaches caches you do not control? | Cost |
|---|---|---|---|---|
| Expire | Set a TTL and wait | Slow (up to one TTL) | Yes, eventually | Free, but stale until then |
| Purge | Delete the stored copy now | Fast | No | One API call per cache layer |
| Version | Change the URL (?v=2, a content hash) |
Instant | Yes, because the old URL is never asked again | You must update every place that links to it |
How it works technically
A screenshot usually passes through several layers, and each holds its own copy:
page changes │ ▼ your render store ──▶ CDN edge ──▶ browser cache (purge: you) (purge: you) (cannot purge; wait for max-age) │ └──▶ Slack / LinkedIn / X image proxy (cannot purge; their own TTL)
A purge works only on the layers on the left that you operate. Everything downstream keeps its copy until its own TTL ends. That is why versioning is the most reliable method for public images: a new URL is a new cache key in every layer at once. The old entry is never requested again and expires by itself.
The hardest part is knowing when to invalidate. HTTP gives you two signals for free: ETag and Last-Modified. If the page you capture sends one of them, you can tie the screenshot version to it.
How to do it
Puppeteer: version by the page's validator
This script puts the page's ETag (or Last-Modified) into the file name. When the page changes, the name changes, so the old screenshot is never served again. The first run printed changed, rendered shots/a2d6da1ca59a.png; the second printed unchanged, reuse ... without starting Chrome.
import puppeteer from 'puppeteer'; import { access, mkdir } from 'node:fs/promises'; import { createHash } from 'node:crypto'; const url = process.argv[2] ?? 'https://example.com/'; const head = await fetch(url, { method: 'HEAD' }); if (!head.ok) throw new Error(`HEAD ${url}: ${head.status}`); const validator = head.headers.get('etag') ?? head.headers.get('last-modified'); if (!validator) throw new Error('No ETag or Last-Modified: fall back to a TTL'); const version = createHash('sha256').update(url + validator).digest('hex').slice(0, 12); const file = `shots/${version}.png`; await mkdir('shots', { recursive: true }); const exists = await access(file).then(() => true, () => false); if (exists) { console.log(`unchanged, reuse ${file}`); } else { const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle0' }); await page.screenshot({ path: file }); console.log(`changed, rendered ${file}`); } finally { await browser.close(); } }
Many dynamic pages send neither header. For those, versioning needs a signal from your own app, such as the record's updated_at.
Playwright: purge by URL prefix, then re-render
When the URL must stay fixed, purge. This keeps an index of page URL to file, deletes every entry under a prefix and renders those pages again. The second run printed purged 1 and wrote a new file:
import { chromium } from 'playwright'; import { readFile, writeFile, rm } from 'node:fs/promises'; const INDEX = 'shots/index.json'; // { "<page url>": "<file>" } const prefix = process.argv[2] ?? 'https://example.com/'; const index = JSON.parse(await readFile(INDEX, 'utf8').catch(() => '{}')); const stale = Object.keys(index).filter((u) => u.startsWith(prefix)); for (const u of stale) { await rm(index[u], { force: true }); delete index[u]; } console.log(`purged ${stale.length}`); const browser = await chromium.launch(); try { const page = await browser.newPage(); for (const u of stale.length ? stale : [prefix]) { await page.goto(u, { waitUntil: 'networkidle' }); const file = `shots/${Date.now()}.png`; await page.screenshot({ path: file }); index[u] = file; } } finally { await browser.close(); } await writeFile(INDEX, JSON.stringify(index, null, 2));
Tested on 2026-10-15 with Node 22.18.0, [email protected] (run with headless: 'shell') and [email protected] against https://example.com.
RenderScreenshot
The API supports all three moves. Expiry is cache.ttl. Purging is POST /v1/cache/purge by key, URL pattern or date. A forced re-render is cache.refresh. In the Node.js SDK:
import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); // 1. Remove every stored render of the blog. const { purged } = await client.cache.purgeUrl('https://example.com/blog/*'); // 2. Render the changed post again and replace what is stored. const image = await client.take( TakeOptions.url('https://example.com/blog/launch').preset('og_card').cacheRefresh() ); console.log(`purged ${purged}, new image ${image.length} bytes`);
One rule surprises people: filters in one purge request add up. An entry is removed if it matches any of them. The cache invalidation guide shows a purge-on-deploy hook and the full filter rules. This SDK snippet was not run for this page.
Common mistakes
- Purging and expecting social networks to update. Their image proxies keep a copy. Change the URL.
- Purging without re-rendering. The next visitor pays the render time. Warm the cache right after the purge.
- Versioning with a timestamp of the render. Every render gets a new URL, and nothing is ever reused. Version by the content, not by the clock.
- Ignoring the URL string.
https://example.com/blogand/blog/are different keys. End purge patterns with*. - Very long TTLs as a default. Long TTLs make every mistake last longer. Choose a TTL per use case.
Related terms
- Cache key: what decides that two requests are "the same".
- Cache TTL: invalidation by waiting.
- CDN edge cache: the layer that needs its own purge.
- Cache-Control header: the rules downstream caches follow.
To try purge and refresh on your own pages, get a free API key.