Friday, 6:40 PM. We had just pushed what looked like a clean release: smaller JavaScript bundles, lighter product images, and a homepage that loaded in under a second on our office Wi-Fi. Ten minutes later, support tickets started landing.
“I removed an item but it still shows in cart.”
“Price changed at checkout.”
“Why is my dashboard greeting someone else?”
None of those bugs were in our app code. They were in our caching rules.
That evening changed how I design performance. Speed is not only about serving content quickly. It is about serving the right content quickly. In this guide, I will walk through the practical HTTP caching strategy we now use for modern web apps, where SSR, APIs, CDNs, and user-specific data all coexist.
Primary keyword: HTTP caching strategy for modern web apps
Secondary keywords: Cache-Control headers, Next.js caching, CDN cache invalidation
The mental model that stopped us from shipping cache bugs
Instead of thinking in routes, think in data risk classes. Every response belongs to one of these buckets:
- Static and versioned: JS, CSS, fonts, hashed assets. Safe to cache aggressively.
- Public but changing: landing pages, product lists, docs pages. Cache with short freshness plus background revalidation.
- User-specific or sensitive: carts, profiles, account pages, token-based API responses. Never shared-cache these.
This lines up with HTTP caching semantics from RFC 9111: freshness, validation, and cache scope. It also matches practical guidance from MDN Cache-Control docs.
A workable baseline policy (that most teams can adopt in a week)
Here is the policy we rolled out across CDN and origin:
- Hashed static assets:
public, max-age=31536000, immutable - Public HTML:
public, s-maxage=300, stale-while-revalidate=60 - Authenticated HTML/API:
private, no-store(orprivate, no-cacheif revalidation is needed) - Error-prone upstream pages: add
stale-if-errorwhere supported to reduce visible outages
Tradeoff: more aggressive caching lowers origin load and cloud bills, but increases stale-data risk. If your domain is finance, healthcare, or inventory-critical commerce, prefer shorter TTLs and stronger validation even if TTFB goes up slightly.
Code block 1, edge and origin headers that do not fight each other
# Nginx example: public catalog pages
location /products/ {
add_header Cache-Control "public, s-maxage=300, stale-while-revalidate=60" always;
add_header Vary "Accept-Encoding" always;
try_files $uri $uri/ /index.php?$args;
}
# Never allow shared caching for account/cart flows
location ~ ^/(account|cart|checkout) {
add_header Cache-Control "private, no-store" always;
add_header Vary "Cookie, Authorization, Accept-Encoding" always;
try_files $uri $uri/ /index.php?$args;
}
# Fingerprinted static assets
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
}
Two rules matter more than people admit:
- Always set
Varyintentionally. Missing or wrongVaryis a common reason users receive the wrong version of content. - Do not mix contradictory directives. For example, avoid sending both
no-storeand longmax-agethrough different layers.
Code block 2, Next.js route design for predictable freshness
Next.js has evolved quickly. The current caching model in the App Router and Cache Components world is powerful, but easy to misuse if you cache runtime-specific data. The docs are explicit about this in their updated guidance (Next.js Caching docs, updated April 2026).
// app/api/products/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const data = await fetch('https://api.example.com/products', {
// Next.js-level fetch cache control
next: { revalidate: 300 },
}).then(r => r.json())
return NextResponse.json(data, {
headers: {
// CDN/browser behavior for public catalog data
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
'Vary': 'Accept-Encoding',
},
})
}
// app/api/me/route.ts (user-specific)
export async function GET() {
const me = await getUserFromSession()
return NextResponse.json(me, {
headers: {
'Cache-Control': 'private, no-store',
'Vary': 'Cookie, Authorization, Accept-Encoding',
},
})
}
Notice the separation: product feeds can be shared-cached, account responses cannot. It sounds obvious, but this is where many “fast demo, broken production” launches fail.
Cache invalidation that is boring on purpose
Every team says cache invalidation is hard. It is hard when invalidation policy depends on humans remembering to press buttons. We reduced incidents by making purge behavior deterministic:
- Tag content by entity, for example
product:123,category:laptops,homepage. - Emit those tags in origin responses when your CDN supports it.
- Purge by tag from CI/CD after content updates, not manually.
Cloudflare documents this model clearly in its cache-tag purge workflow (Purge by cache-tags). Tradeoff: tags add operational discipline requirements. If your tagging taxonomy is messy, purge blasts become unpredictable.
How this ties back to the rest of your stack
Caching is not an isolated performance trick. It affects reliability, security, and cost:
- If your APIs already use idempotent processing patterns, you can revalidate more safely under load. Related read: idempotent webhook processing in Node.js.
- If your frontend observability is mature, you can detect cache regressions before users report them. Related read: frontend observability with Web Vitals and OTel.
- If your CSP rollout is strict, check that cache layers are not serving stale inline-policy variants. Related read: strict CSP on WordPress.
- If your cloud bill is rising, improving cache-hit ratio on safe public routes can be the fastest cost lever. Related read: cloud cost optimization guide.
Troubleshooting: 6 failures you will likely see (and the fastest fix)
1) Users see someone else’s data
Likely cause: personalized response cached in a shared layer.
Fix: set Cache-Control: private, no-store, add Vary: Cookie, Authorization, purge affected keys immediately.
2) Deploy completed but UI still shows old content
Likely cause: long edge TTL without targeted purge.
Fix: purge by tag or surrogate key, then verify with response headers (Age, CF-Cache-Status, CDN equivalent).
3) TTFB is great, but conversion drops
Likely cause: stale prices, inventory, or promo states on key pages.
Fix: shorten s-maxage for transactional pages; keep long TTL only for static assets.
4) API latency spikes right after purge
Likely cause: thundering herd from synchronized misses.
Fix: use stale-while-revalidate, request coalescing, and staggered invalidation where possible.
5) Browser keeps revalidating hashed assets
Likely cause: missing immutable or inconsistent asset fingerprinting.
Fix: enforce content-hash filenames and add public, max-age=31536000, immutable.
6) Debugging is impossible because cache headers differ by path
Likely cause: policy drift across app, CDN, and origin teams.
Fix: create a versioned caching matrix in repo and test headers in CI for representative routes.
FAQ
Q1) Is no-cache the same as no-store?
No. no-cache allows storage but requires revalidation before reuse. no-store means do not store at all. For sensitive authenticated responses, no-store is usually safer.
Q2) Should I cache HTML at the CDN for logged-in users?
Usually no, unless you have strict key segmentation and fully audited personalization boundaries. In most teams, the safer default is shared-cache public HTML and private no-store for logged-in HTML.
Q3) What metric tells me my strategy is working?
Track at least four together: cache-hit ratio, p95 TTFB, origin request rate, and user-impact metrics (checkout success or task completion). A hit ratio increase alone can hide stale-data regressions.
Actionable takeaways for this week
- Create a route-by-route cache classification sheet: static, public-dynamic, private-sensitive.
- Standardize 3 header presets and apply them consistently across origin and framework responses.
- Automate purge-by-tag in your deployment pipeline for content updates.
- Instrument cache headers in logs and dashboards so stale-data incidents become observable.
- Run one game-day: simulate stale catalog plus checkout and validate alerting plus rollback.
If you remember only one line from this article, keep this one: cache aggressively only where correctness is cheap, and validate aggressively where correctness is expensive. That single rule has saved us more than any fancy optimization ever did.

Leave a Reply