Browser Caching Explained: How to Cache HTML, Static Assets, and API Responses Correctly

A few years ago, I deployed what I thought was a solid performance optimization. Bundled assets, lazy-loaded images, trimmed dependencies. The Lighthouse score looked healthy. But users on repeat visits still reported the app feeling sluggish, and I couldn't immediately explain why.

The culprit was caching — not the lack of it, but the wrong kind in the wrong places.

The short version: HTML should be short-lived, hashed static assets should be cached aggressively, and API responses require deliberate, per-endpoint decisions. The rest of this article is the reasoning behind that.


How Browser Caching Works

Browsers maintain several cache layers for previously fetched resources; HTTP headers determine whether a resource can be reused, revalidated, or fetched again.

It helps to be clear about which cache we're talking about, because the term gets used loosely:

  • Browser cache — the local disk cache managed by the browser for a specific user
  • HTTP cache — the broader caching model defined by HTTP headers, which applies to both browsers and intermediaries
  • CDN / proxy cache — a shared cache sitting between your users and your origin server; multiple users benefit from the same cached response
  • Service Worker cache — a programmable cache layer controlled by JavaScript, separate from the HTTP cache entirely

These layers interact. A CDN can serve a cached response before the browser even makes a request. A Service Worker can intercept requests before the browser checks its own cache. Understanding the distinction matters when something doesn't behave the way you expect.


The Caching Toolkit

Cache-Control

Cache-Control is the primary mechanism for expressing caching intent. A few directives worth knowing precisely:

  • max-age=N — cache this resource for N seconds
  • no-store — do not cache at all; fetch fresh every time
  • no-cache — you may cache it, but revalidate with the server before using the cached copy
  • public — any cache (browser, CDN, proxy) may store this response
  • private — only the end user's browser should cache this; CDNs must not store it
  • immutable — the resource at this URL will never change; skip revalidation even on manual refresh
  • stale-while-revalidate=N — serve the cached version immediately, then refresh it in the background within N seconds

stale-while-revalidate is worth highlighting: it's a Cache-Control directive available at the HTTP layer, not exclusive to Service Workers. It's a practical way to keep perceived load times fast while still refreshing content regularly.

# Hashed static asset — safe to cache for a year
Cache-Control: public, max-age=31536000, immutable

# HTML entry point — always revalidate
Cache-Control: no-cache

# Public API response, fresh for 5 minutes, stale acceptable for 1 minute more
Cache-Control: public, max-age=300, stale-while-revalidate=60

# Authenticated or personalized response — never share via CDN
Cache-Control: private, no-cache
        

ETags and Last-Modified

These are validation mechanisms, not independent caching strategies. They work in concert with Cache-Control. Once a cached resource expires (or has no-cache set), the browser sends the ETag or Last-Modified value back to the server with the next request. If the resource hasn't changed, the server responds with 304 Not Modified — no body, minimal bandwidth. If it has changed, the full updated resource comes back.

ETags are more reliable than Last-Modified because they're content-based rather than time-based, and servers sometimes have imprecise timestamps.

Content Hashing (Cache Busting)

Embed a content hash in the filename — main.a3f92b.js — so that a new build always produces a new URL. The browser treats it as a different resource and fetches fresh. Combined with a long max-age, this gives you the best of both worlds: aggressive caching with guaranteed freshness on every deploy.

Every major bundler (Webpack, Vite, esbuild, Rollup) supports this by default or with minimal configuration.

Service Workers

Service Workers give you full programmatic control over caching — intercept any request, decide whether to serve from cache, hit the network, or combine both. This is how offline support works in progressive web apps. The trade-off is real complexity: a misconfigured Service Worker can cache things you never intended to cache and surface bugs that are hard to reproduce. Reach for this when HTTP headers alone aren't sufficient, not as a first instinct.


Where Deployments Go Wrong

The most common mistake is treating all assets the same. Your index.html and your logo.a3f92b.png should not share a Cache-Control header.

The stale-HTML problem is real: if a user's browser has aggressively cached your HTML, and you push a new deployment, that cached HTML still references the old asset filenames. If those old filenames are no longer on the server, the page breaks.

The mitigation is two-fold. First, keep prior hashed assets available on the server for a short period after each deployment — long enough for in-flight sessions to complete. Second, consider atomic deployments where the old and new asset versions coexist briefly. Most CDNs and deployment platforms support this. The risk is much lower when both are in place.


API Responses Deserve More Attention

Browsers cache JSON responses too, but only when the response headers explicitly permit it. If your server doesn't set Cache-Control on API responses, behaviour varies by browser and is generally undefined — not a good position to be in.

A few things to get right:

Public vs. private. If a response contains data specific to a logged-in user, it must use Cache-Control: private. Using public on a personalized response risks a CDN serving one user's data to another.

The Vary header. If your response differs based on request headers — Accept-Language, Accept-Encoding, or Authorization — you need to include Vary so caches know to store separate copies per variant. Missing Vary is a common source of caching bugs that only appear in production.

When not to cache. Real-time data, cart contents, notifications, anything that reflects live server state — these should generally be excluded from HTTP caching. Use Cache-Control: no-store and manage freshness at the application layer instead.


Quick Reference

Resource type Recommended header Reasoning Hashed JS / CSS public, max-age=31536000, immutable URL changes on every build; safe to cache permanently Images with hash public, max-age=31536000, immutable Same as above HTML entry points no-cache + ETag Always revalidate; 304 keeps it fast Fonts public, max-age=31536000, immutable Rarely change; hash the filename Public API (stable data) public, max-age=300, stale-while-revalidate=60 Allows CDN caching; background refresh Authenticated API private, no-cache User-specific; CDN must not store Real-time / live data no-store Never cache; freshness is the product


A Deployment Checklist

Before shipping, it's worth running through these quickly:

  • [ ] Static assets have content hashes in their filenames
  • [ ] HTML files use no-cache with ETag support, not a long max-age
  • [ ] Personalized or authenticated API responses use Cache-Control: private
  • [ ] Vary is set correctly on responses that differ by request header
  • [ ] Prior asset versions remain available briefly post-deployment
  • [ ] DevTools → Network tab confirms the expected headers are actually being sent
  • [ ] A hard refresh after deployment doesn't surface stale UI


Closing Thought

The mental model is straightforward: you're deciding, for each type of resource, whether freshness or speed takes priority — and making that decision explicit rather than leaving it to browser defaults.

The details are what trip people up. But most of the work is done by a few lines of configuration, applied with precision. Open DevTools on your own app right now and check what your assets are actually sending. That single check usually tells you more than any amount of reading.


Thanks for reading. If you're wrestling with a specific caching scenario at work, drop it in the comments — these problems are almost always more interesting than they look.

To view or add a comment, sign in

More articles by Prashant Kumar Singh

Explore content categories