|$ curl https://forge-ai.dev/api/markdown?path=docs/performance/caching
$cat docs/caching-strategies.md
updated Recently·38 min read·published

Caching Strategies

PerformanceHTTPService WorkersCDNIntermediate🎯Free Tools
Introduction

Caching is the process of storing copies of resources so they can be served faster on subsequent requests. It is one of the most effective performance optimizations — reducing load times from seconds to milliseconds by eliminating network round trips entirely.

Effective caching operates at multiple layers: the browser cache (HTTP), service workers (programmatic), CDN/edge caches (geographic distribution), and application caches (server-side). Each layer serves a different purpose and has different invalidation strategies.

HTTP Cache Headers

HTTP cache headers control how browsers and proxies cache responses. Understanding these headers is fundamental to web performance — they determine whether a resource is fetched from cache or network on every visit.

cache-control-directives.txt
TEXT
1Cache-Control Header Directives:
2
3 no-cache — Cache the response, but revalidate before every use
4 no-store — Never cache the response (always fetch from network)
5 max-age=N — Cache the response for N seconds
6 s-maxage=N — Cache for CDN/proxies for N seconds (overrides max-age)
7 must-revalidate — Once expired, must revalidate with server
8 immutable — Response will never change (skip revalidation entirely)
9 public — Any cache can store the response (browser, CDN, proxy)
10 private — Only the user's browser can cache (not CDN/proxy)
11 stale-while-revalidate=N — Serve stale cache, revalidate in background
12 stale-if-error=N — Serve stale cache if server returns error
13
14Common Cache-Control Patterns:
15
16 # Static assets with content hash (immutable)
17 Cache-Control: public, max-age=31536000, immutable
18
19 # HTML pages (always fresh)
20 Cache-Control: no-cache
21
22 # API responses (never cache)
23 Cache-Control: no-store
24
25 # Semi-dynamic content (revalidate frequently)
26 Cache-Control: public, max-age=60, stale-while-revalidate=3600
27
28 # User-specific content (private cache only)
29 Cache-Control: private, max-age=300
30
31 # CDN-cached content (long browser cache, long CDN cache)
32 Cache-Control: public, max-age=86400, s-maxage=604800
cache-headers.js
TypeScript
1// Node.js / Express — Setting cache headers
2import express from "express";
3const app = express();
4
5// Static assets with content hash (best for long-term caching)
6app.use("/static", express.static("public", {
7 maxAge: "1y",
8 immutable: true,
9 setHeaders: (res, filePath) => {
10 if (filePath.endsWith(".html")) {
11 // HTML files should always be revalidated
12 res.setHeader("Cache-Control", "no-cache");
13 }
14 },
15}));
16
17// API responses — stale-while-revalidate pattern
18app.get("/api/products", (req, res) => {
19 res.setHeader("Cache-Control", "public, max-age=60, stale-while-revalidate=3600");
20 res.setHeader("ETag", generateETag(products));
21 res.json(products);
22});
23
24// User-specific content — private cache only
25app.get("/api/dashboard", (req, res) => {
26 res.setHeader("Cache-Control", "private, max-age=300");
27 res.json(getUserDashboard(req.user.id));
28});
29
30// Never cache sensitive content
31app.get("/api/account", (req, res) => {
32 res.setHeader("Cache-Control", "no-store");
33 res.json(getAccountInfo(req.user.id));
34});
35
36// ETag validation
37function generateETag(data: any): string {
38 const hash = require("crypto")
39 .createHash("md5")
40 .update(JSON.stringify(data))
41 .digest("hex");
42 return `"${hash}"`;
43}
44
45// Conditional responses (304 Not Modified)
46app.get("/api/data", (req, res) => {
47 const data = getData();
48 const etag = generateETag(data);
49
50 if (req.headers["if-none-match"] === etag) {
51 return res.status(304).end();
52 }
53
54 res.setHeader("ETag", etag);
55 res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
56 res.json(data);
57});
Resource TypeCache-ControlRationale
JS/CSS (hashed)public, max-age=31536000, immutableContent hash changes on update, safe to cache forever
Images (hashed)public, max-age=31536000, immutableSame as JS/CSS — content-addressable
HTML pagesno-cacheAlways revalidate — entry point must be fresh
API (public)public, max-age=60, s-maxage=300Short browser cache, longer CDN cache
API (private)private, max-age=300User-specific data, browser only
Fontspublic, max-age=31536000, immutableRarely change, safe to cache long-term
Sensitive datano-storeNever cache auth tokens, PII, financial data

best practice

Use content hashing in filenames (e.g., app.a3f8b2c1.js) for static assets. This allows you to set long max-age values with immutable — the URL changes when the content changes, so the browser always gets the correct version.
Stale-While-Revalidate

The stale-while-revalidate pattern serves cached content immediately while updating the cache in the background. Users get instant responses while fresh data is fetched asynchronously. This is the ideal pattern for semi-dynamic content.

stale-while-revalidate.js
TypeScript
1// stale-while-revalidate pattern in Service Worker
2// sw-cache.js
3const SWR_CACHE = "stale-while-revalidate-v1";
4const API_CACHE = "api-cache-v1";
5
6self.addEventListener("fetch", (event) => {
7 const url = new URL(event.request.url);
8
9 // Apply SWR to API requests
10 if (url.pathname.startsWith("/api/")) {
11 event.respondWith(swrFetch(event.request));
12 return;
13 }
14});
15
16async function swrFetch(request) {
17 const cache = await caches.open(API_CACHE);
18
19 // 1. Try to get cached response
20 const cachedResponse = await cache.match(request);
21
22 // 2. Always fetch fresh data in background
23 const fetchPromise = fetch(request).then(async (networkResponse) => {
24 // 3. Update cache with fresh response
25 if (networkResponse.ok) {
26 await cache.put(request, networkResponse.clone());
27 }
28 return networkResponse;
29 }).catch(() => {
30 // Network failed — return cached if available
31 return cachedResponse;
32 });
33
34 // 4. Return cached immediately if available, otherwise wait for network
35 return cachedResponse || fetchPromise;
36}
37
38// More advanced: time-based SWR with max-age
39async function timeAwareSWR(request) {
40 const cache = await caches.open(API_CACHE);
41 const cachedResponse = await cache.match(request);
42
43 if (cachedResponse) {
44 const dateHeader = cachedResponse.headers.get("sw-cached-at");
45 const cachedAt = dateHeader ? parseInt(dateHeader) : 0;
46 const age = Date.now() - cachedAt;
47
48 // If cache is younger than max-age, return immediately
49 if (age < 60000) { // 60 seconds
50 // Revalidate in background if stale
51 if (age > 30000) { // 30 seconds
52 fetchAndCache(request, cache);
53 }
54 return cachedResponse;
55 }
56 }
57
58 // Cache miss or expired — fetch from network
59 return fetchAndCache(request, cache);
60}
61
62async function fetchAndCache(request, cache) {
63 try {
64 const response = await fetch(request);
65 if (response.ok) {
66 const responseToCache = response.clone();
67 responseToCache.headers.set("sw-cached-at", Date.now().toString());
68 await cache.put(request, responseToCache);
69 }
70 return response;
71 } catch (error) {
72 // Network failed, try stale cache
73 const stale = await cache.match(request);
74 if (stale) return stale;
75 throw error;
76 }
77}
🔥

pro tip

stale-while-revalidate is supported natively in Cache-Control headers by most CDNs (Fastly, Cloudflare, Akamai). If your CDN supports it, configure it at the CDN level instead of implementing it in your service worker — simpler and more efficient.
Service Worker Caching

Service workers give you programmatic control over caching. They act as a proxy between the browser and network, intercepting requests and serving cached responses. This enables offline support, background updates, and fine-grained caching strategies.

sw-advanced.js
TypeScript
1// Service Worker with multiple caching strategies
2// sw.js
3const STATIC_CACHE = "static-v1";
4const DYNAMIC_CACHE = "dynamic-v1";
5const IMAGE_CACHE = "images-v1";
6
7const STATIC_ASSETS = [
8 "/",
9 "/app.js",
10 "/styles.css",
11 "/offline.html",
12];
13
14// Install — cache static assets
15self.addEventListener("install", (event) => {
16 event.waitUntil(
17 caches.open(STATIC_CACHE).then((cache) => {
18 return cache.addAll(STATIC_ASSETS);
19 })
20 );
21 self.skipWaiting();
22});
23
24// Activate — clean old caches
25self.addEventListener("activate", (event) => {
26 event.waitUntil(
27 caches.keys().then((keys) => {
28 return Promise.all(
29 keys
30 .filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE && key !== IMAGE_CACHE)
31 .map((key) => caches.delete(key))
32 );
33 })
34 );
35 self.clients.claim();
36});
37
38// Fetch — routing to different caching strategies
39self.addEventListener("fetch", (event) => {
40 const { request } = event;
41 const url = new URL(request.url);
42
43 // Skip non-GET requests
44 if (request.method !== "GET") return;
45
46 // Static assets — Cache First (immutable)
47 if (url.pathname.startsWith("/static/") || url.pathname.match(/\.\w{8}\.(js|css)$/)) {
48 event.respondWith(cacheFirst(request, STATIC_CACHE));
49 return;
50 }
51
52 // Images — Cache First with network fallback
53 if (request.destination === "image") {
54 event.respondWith(cacheFirst(request, IMAGE_CACHE));
55 return;
56 }
57
58 // API — Network First with cache fallback
59 if (url.pathname.startsWith("/api/")) {
60 event.respondWith(networkFirst(request, DYNAMIC_CACHE));
61 return;
62 }
63
64 // HTML pages — Network First with offline fallback
65 if (request.headers.get("accept")?.includes("text/html")) {
66 event.respondWith(networkFirst(request, DYNAMIC_CACHE, "/offline.html"));
67 return;
68 }
69
70 // Everything else — Stale While Revalidate
71 event.respondWith(staleWhileRevalidate(request, DYNAMIC_CACHE));
72});
73
74// Cache First — fastest, good for immutable assets
75async function cacheFirst(request, cacheName) {
76 const cached = await caches.match(request);
77 if (cached) return cached;
78
79 try {
80 const response = await fetch(request);
81 if (response.ok) {
82 const cache = await caches.open(cacheName);
83 cache.put(request, response.clone());
84 }
85 return response;
86 } catch {
87 return new Response("Offline", { status: 503 });
88 }
89}
90
91// Network First — best for dynamic content
92async function networkFirst(request, cacheName, fallbackUrl) {
93 try {
94 const response = await fetch(request);
95 if (response.ok) {
96 const cache = await caches.open(cacheName);
97 cache.put(request, response.clone());
98 }
99 return response;
100 } catch {
101 const cached = await caches.match(request);
102 if (cached) return cached;
103 if (fallbackUrl) {
104 return caches.match(fallbackUrl);
105 }
106 return new Response("Offline", { status: 503 });
107 }
108}
109
110// Stale While Revalidate — balanced for semi-dynamic content
111async function staleWhileRevalidate(request, cacheName) {
112 const cache = await caches.open(cacheName);
113 const cached = await cache.match(request);
114
115 const fetchPromise = fetch(request).then((response) => {
116 if (response.ok) {
117 cache.put(request, response.clone());
118 }
119 return response;
120 }).catch(() => cached);
121
122 return cached || fetchPromise;
123}
StrategyWhen to UseLatencyFreshness
Cache FirstStatic assets, images, fontsInstant (cache hit)Stale possible
Network FirstAPI, HTML, dynamic dataNetwork latencyAlways fresh
Stale While RevalidateSemi-dynamic content, searchInstant (stale) + freshEventually fresh
Network OnlyPayments, auth, sensitiveNetwork onlyAlways fresh
Cache OnlyOffline-first appsInstantFrozen in time

info

Use workbox-webpack-plugin or next-pwa to generate service workers automatically. Manual service worker code is error-prone — especially cache invalidation. These libraries handle precaching, runtime caching, and versioning out of the box.
CDN Caching

Content Delivery Networks (CDNs) cache content at edge locations worldwide, reducing latency by serving resources from the nearest geographic location. CDN caching reduces TTFB, offloads your origin server, and improves reliability.

cdn-caching.sh
Bash
1# Cloudflare Cache Rules (via dashboard or API)
2# Rule 1: Cache static assets for 1 year
3# Match: *.js, *.css, *.woff2, *.webp, *.avif
4# Cache-Control: public, max-age=31536000, immutable
5
6# Rule 2: Cache API responses for 5 minutes
7# Match: /api/products/*, /api/categories/*
8# Cache-Control: public, max-age=300, s-maxage=600
9
10# Rule 3: Edge Caching with Purge
11# Purge specific URLs after deploy
12curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
13 -H "Authorization: Bearer {api_token}" \
14 -d '{"files":["https://example.com/app.js","https://example.com/styles.css"]}'
15
16# Vercel Edge Caching (via vercel.json)
17# vercel.json
18{
19 "headers": [
20 {
21 "source": "/static/(.*)",
22 "headers": [
23 { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
24 ]
25 },
26 {
27 "source": "/api/(.*)",
28 "headers": [
29 { "key": "Cache-Control", "value": "public, s-maxage=60, stale-while-revalidate=300" }
30 ]
31 }
32 ]
33}
34
35# Netlify Headers (_headers file)
36# _headers
37/static/*
38 Cache-Control: public, max-age=31536000, immutable
39
40/api/*
41 Cache-Control: public, s-maxage=60, stale-while-revalidate=300
42
43/*
44 X-Content-Type-Options: nosniff

warning

CDN caching of HTML pages can cause stale content issues. Use s-maxage with shorter values for HTML, or set CDN-Cache-Control: no-store if you need to bypass CDN caching for specific routes. Always test cache behavior with curl -I to verify headers.
Cache Invalidation

Cache invalidation is one of the hardest problems in computer science. The goal is to serve stale content as rarely as possible without unnecessary re-fetching. There are several proven strategies for different scenarios.

cache-invalidation.ts
TypeScript
1// Strategy 1: Content hashing (best for static assets)
2// Build output: app.a3f8b2c1.js, styles.e7d9f4b2.css
3// HTML references hashed filenames — old URLs become invalid on deploy
4
5// In your build tool config:
6// Webpack
7output: {
8 filename: "[name].[contenthash:8].js",
9 chunkFilename: "[name].[contenthash:8].chunk.js",
10}
11
12// Vite
13build: {
14 rollupOptions: {
15 output: {
16 entryFileNames: "[name].[hash].js",
17 chunkFileNames: "chunks/[name].[hash].js",
18 },
19 },
20}
21
22// Strategy 2: Version-based invalidation
23// Append version query parameter to bust cache
24const APP_VERSION = "2.1.0";
25const scriptUrl = `/app.js?v=${APP_VERSION}`;
26
27// Strategy 3: Cache tags (advanced CDN invalidation)
28// Cloudflare Cache-Tag header
29app.use("/static", express.static("public", {
30 setHeaders: (res, filePath) => {
31 if (filePath.endsWith(".js")) {
32 res.setHeader("Cache-Tag", "static,js,app");
33 }
34 },
35}));
36
37// Purge by tag (Cloudflare)
38fetch(`https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache`, {
39 method: "POST",
40 headers: { "Authorization": "Bearer {token}" },
41 body: JSON.stringify({ tags: ["static"] }), // Purge all "static" tagged resources
42});
43
44// Strategy 4: Service Worker update on deploy
45// sw.js — check for updates on each page load
46self.addEventListener("install", (event) => {
47 self.skipWaiting(); // Activate new SW immediately
48});
49
50self.addEventListener("activate", (event) => {
51 event.waitUntil(
52 caches.keys().then((keys) => {
53 return Promise.all(
54 keys.filter((key) => key !== CURRENT_CACHE).map((key) => caches.delete(key))
55 );
56 })
57 );
58 self.clients.claim(); // Take control of all pages immediately
59});
60
61// Notify users of updates
62let newWorker;
63navigator.serviceWorker.register("/sw.js").then((reg) => {
64 reg.addEventListener("updatefound", () => {
65 newWorker = reg.installing;
66 newWorker.addEventListener("statechange", () => {
67 if (newWorker.state === "activated") {
68 // Show update banner
69 showUpdateBanner();
70 }
71 });
72 });
73});

info

The golden rule: never mutate cached URLs. If content changes, the URL must change. Use content hashing for static assets, version query parameters for dynamic resources, and cache tags for CDN-level purging.
Next.js Caching

Next.js has a multi-layer caching system that can be confusing. Understanding each layer and how to control it is essential for building performant Next.js applications.

nextjs-caching.ts
TypeScript
1// Next.js Caching Layers:
2
3// 1. Request Memoization (per-render, in-memory)
4// Data fetched in React Server Components is memoized during render
5// Automatic — no configuration needed
6
7// 2. Data Cache (persisted, across requests)
8// Server-side cache for fetch() results, database queries, etc.
9// Persists across deployments unless explicitly revalidated
10
11// Force no cache (always fetch fresh)
12const data = await fetch("https://api.example.com/data", {
13 cache: "no-store",
14});
15
16// Cache with revalidation
17const data = await fetch("https://api.example.com/data", {
18 next: { revalidate: 3600 }, // Revalidate every hour
19});
20
21// Cache forever (manual invalidation only)
22const data = await fetch("https://api.example.com/data", {
23 next: { tags: ["products"] }, // Tag for manual revalidation
24});
25
26// 3. Full Route Cache (static rendering)
27// Next.js caches the rendered HTML and RSC payload of static routes
28// To opt out: use dynamic functions or export const dynamic = "force-dynamic"
29
30// 4. Router Cache (client-side, in-memory)
31// Caches RSC payloads of visited routes in the browser
32// Duration: session for static, 30 seconds for dynamic
33
34// On-demand revalidation
35import { revalidateTag, revalidatePath } from "next/cache";
36
37// Revalidate by tag
38app.post("/api/revalidate", (req, res) => {
39 revalidateTag("products");
40 res.json({ revalidated: true });
41});
42
43// Revalidate by path
44app.post("/api/revalidate-path", (req, res) => {
45 revalidatePath("/products");
46 res.json({ revalidated: true });
47});
48
49// Time-based revalidation in route handler
50export const revalidate = 3600; // Revalidate this route every hour
51
52export async function GET() {
53 const data = await fetch("https://api.example.com/data", {
54 next: { revalidate: 3600 },
55 });
56 return Response.json(await data.json());
57}

warning

Next.js fetch() is automatically cached in Server Components. To ensure fresh data, explicitly set cache: "no-store" or next: { revalidate: N }. Without these, you may serve unexpectedly stale data.
Debugging Cache Behavior

Debugging caching issues requires checking multiple layers. A resource might be cached at the browser level, service worker level, CDN level, or server level — and each has different headers and invalidation behavior.

debug-cache.sh
Bash
1# Check cache headers from the command line
2curl -I https://example.com/app.js 2>/dev/null | grep -i "cache-control\|etag\|expires\|age\|x-cache"
3
4# Expected output for static assets:
5# cache-control: public, max-age=31536000, immutable
6# etag: "a3f8b2c1"
7# x-cache: HIT (CDN cache hit)
8
9# Check if service worker is intercepting
10# In Chrome DevTools > Application > Service Workers
11# Or check via URL: chrome://serviceworker-internals/
12
13# Clear browser cache (Cmd+Shift+R for hard reload)
14# Clear service worker: Application > Service Workers > Unregister
15
16# Check cached resources
17# Chrome DevTools > Application > Cache Storage
18# Or: Chrome DevTools > Network > Size column (shows "from disk cache")
19
20# Test cache behavior
21# 1. First load: check Network tab — status should be 200
22# 2. Reload: check Network tab — status should be 304 (ETag) or 200 (cache)
23# 3. Check Age header — shows how long the resource has been cached at CDN
24
25# WebPageTest — test caching from multiple locations
26# Run test with "Empty Cache" and "Repeat View" comparison
27
28# Lighthouse audit for caching
29npx lighthouse https://example.com --only-categories=performance --output=json | \
30 jq '.audits.uses-long-cache-ttl'
31
cache-debug.ts
TypeScript
1// Client-side cache debugging utility
2function debugCache() {
3 const results: Record<string, any> = {};
4
5 // Check service worker status
6 if ("serviceWorker" in navigator) {
7 navigator.serviceWorker.getRegistrations().then((regs) => {
8 results.serviceWorkers = regs.map((reg) => ({
9 scope: reg.scope,
10 active: !!reg.active,
11 installing: !!reg.installing,
12 waiting: !!reg.waiting,
13 }));
14 });
15 }
16
17 // Check cache storage
18 if ("caches" in window) {
19 caches.keys().then((keys) => {
20 results.cacheNames = keys;
21 return Promise.all(
22 keys.map((name) =>
23 caches.open(name).then((cache) =>
24 cache.keys().then((keys) => ({
25 name,
26 count: keys.length,
27 urls: keys.map((req) => req.url),
28 }))
29 )
30 )
31 );
32 }).then((cachesInfo) => {
33 results.caches = cachesInfo;
34 console.table(cachesInfo);
35 });
36 }
37
38 // Check resource timing for cache hits
39 const resources = performance.getEntriesByType("resource");
40 const cacheStats = {
41 total: resources.length,
42 cached: resources.filter((r) =>
43 (r as PerformanceResourceTiming).transferSize === 0
44 ).length,
45 fromNetwork: resources.filter((r) =>
46 (r as PerformanceResourceTiming).transferSize > 0
47 ).length,
48 };
49
50 results.cacheStats = cacheStats;
51 console.log("Cache hit rate:", (cacheStats.cached / cacheStats.total * 100).toFixed(1) + "%");
52
53 return results;
54}
Best Practices
Use content hashing in filenames for static assets — enables immutable long-term caching
Set Cache-Control: no-cache for HTML pages — always revalidate the entry point
Implement stale-while-revalidate for semi-dynamic API responses
Use private cache for user-specific data — never cache auth tokens or PII
Leverage CDN caching with s-maxage for public resources — reduces TTFB globally
Use service workers for offline support and fine-grained caching strategies
Always have a cache invalidation strategy before implementing caching
Test cache behavior with curl -I and browser DevTools — never assume caching works
Monitor cache hit rates in production — low hit rates indicate misconfiguration
Use Workbox for service worker development — manual SW code is error-prone
$Blueprint — Engineering Documentation·Section ID: PERF-CACHE-01·Revision: 1.0