This content originally appeared on DEV Community and was authored by Darkø Tasevski
If you’ve ever pushed a change to production and your browser stubbornly kept showing you the old version, you’ve experienced caching, probably not in a good way.
HTTP caching is one of those topics that can seem deceptively simple (“it just stores stuff locally”), but the details matter. Done right, it makes sites feel lightning-fast and reduces server load. Done wrong, it causes confusing bugs and outdated resources to linger.
This isn’t a deep dive into every caching strategy, it’s the quick, developer-focused overview I wish I had when I first started building for the web. At the end, you’ll find resources for going deeper.
Why We Cache
Fetching resources over the network is slow and expensive, not just in terms of latency, but also in wasted bandwidth and server strain.
Caching lets us store a copy of a resource (HTML, CSS, JS, images, fonts, etc.) so that when it’s requested again, the browser can serve it directly from local storage instead of hitting the network. This reduces load times, network traffic, and the chance that a user gets a partially loaded or broken page.
The process is simple in theory:
- Browser requests a resource.
- Browser checks its cache for a valid copy.
- If it finds one, it serves it immediately.
- If not, it fetches from the server and may store a copy for next time.
It’s not the most flexible system, you have limited control over lifetimes, but it’s built into every browser and requires minimal setup to get started.
How Browser Caching Works
Before diving into headers and strategies, it helps to understand the mechanics of how browsers actually decide whether to serve something from cache or go back to the network.
The Cache Decision Process
When a browser requests a resource, it doesn’t just blindly fetch it. Instead, it follows a decision tree that looks roughly like this:
Is the resource in cache?
├─ No -> Fetch from network, store in cache
└─ Yes -> Is the cached copy fresh?
├─ Yes -> Serve from cache
└─ No -> Send conditional request to server
├─ 304 Not Modified -> Use cached copy, update freshness
└─ 200 OK -> Replace cached copy with new version
This process explains why you sometimes see “304 Not Modified” in DevTools: the browser already had the file, it just needed the server to confirm it was still valid.
What Defines the Cache Key
It’s also worth knowing that browsers don’t cache purely by URL. The cache key includes several factors:
- The URL itself – the primary identifier.
-
The HTTP method –
GET
requests are typically cacheable, whilePOST
,PUT
, andDELETE
are not. -
The
Vary
header – if present, it tells the cache to consider certain request headers (likeAccept-Encoding
orUser-Agent
) as part of the key. - Authentication state – private resources may be cached separately per user to avoid cross-user leaks.
Understanding this process makes the rest of the article easier to follow: headers like Cache-Control
, ETag
, and Last-Modified
plug directly into this decision-making loop, shaping whether the browser trusts what it already has or goes back to the server.
The Key HTTP Response Headers
You’ll usually configure caching at the response header level (the browser handles request headers for you). The main players:
-
Cache-Control
, Defines how and for how long (in seconds, relative to the request time) the browser should cache a resource (e.g.,max-age=8640000
for 100 days). -
Expires
, Sets a timestamp after which the resource is considered stale. Overridden byCache-Control
if both are present. -
Last-Modified
, Timestamp indicating when the resource last changed; used for conditional requests. -
ETag
, A unique identifier (often a hash) for a resource version. Any change to the file should generate a new ETag.
Headers like Cache-Control
and Expires
rely on time-based freshness. Last-Modified
and ETag
use validation, the browser asks the server if the cached copy is still valid, and the server replies with a 304 Not Modified
if nothing has changed.
Example:
# Static assets with content hashing
Cache-Control: public, max-age=31536000, immutable
# HTML pages that reference changing assets
Cache-Control: no-cache
# API responses that are user-specific
Cache-Control: private, max-age=300
# Critical resources that must be fresh
Cache-Control: no-store
# CDN-optimized content
Cache-Control: public, max-age=3600, s-maxage=86400
A Closer Look at Cache-Control
If there’s one header that defines how caching really behaves, it’s Cache-Control
. Think of it less as a single setting and more as a collection of knobs you can tune depending on what you’re serving. The most common is max-age
, which tells the browser how long a file should be treated as fresh. A script with Cache-Control: max-age=86400
will stay valid for a full day before the browser asks the server again.
Not all content should linger that long. For sensitive responses, banking APIs, for example, you’d go with no-store
, which prevents caching altogether. no-cache
is more subtle: it still allows the browser to keep a copy, but forces it to check with the server before reusing it. That makes it a good fit for HTML pages that often reference new versions of CSS or JavaScript bundles.
Some directives are about what happens after a file goes stale. With must-revalidate
, the browser has no choice but to confirm with the server before showing the file again. By contrast, stale-while-revalidate
gives you a performance trick: the browser can serve the old file instantly while quietly fetching the fresh one in the background. Combined with long cache times, this creates the illusion of zero-delay updates.
Another important distinction is whether content is meant for just one user or many. Marking a resource as private
keeps it in the user’s browser only, while public
allows intermediaries like CDNs to cache and share it across requests. This small distinction often decides whether your API response leaks into a shared cache or stays safely scoped to the user.
Finally, if you’re serving versioned or hashed files that will never change, you can declare them immutable
. This tells the browser it doesn’t even need to check back with the server once a file is cached, making it perfect for assets like fonts, image sprites, or JavaScript bundles. The directive really shines when paired with modern bundlers such as webpack, Vite, or Rollup, which generate files with unique content hashes in their names (app.3f29c1.js
). Each new build produces new filenames, so the browser sees them as brand-new resources. The result is the best of both worlds: current files are cached aggressively and served instantly on repeat visits, while new builds automatically invalidate the old ones.
Performance Implications
Each directive affects performance differently:
-
immutable
: Fastest – no network requests for cached resources -
max-age
: Fast for fresh content, validation for stale -
no-cache
: Always requires validation request -
no-store
: Always requires full request
In practice, most setups end up with a mix: long-lived, immutable caching for static assets; private or no-store for anything user-specific; and short-lived or no-cache for HTML documents. The art lies in combining these directives so that your users always get a fast response without getting stuck on outdated code.
Avoiding Stale Responses
Sometimes you don’t want the browser to serve a cached file, especially right after deploying a new CSS or JavaScript bundle. In development, the quick fix is to disable caching entirely in your browser’s DevTools, but in production you need a more deliberate strategy.
One common approach is cache busting. Instead of reusing the same filename, you append a unique query string or, more reliably, let a bundler like webpack, Vite, or Rollup generate hashed filenames automatically (app.3f29c1.js
). Each new build produces new filenames, so the browser treats them as brand-new resources, sidestepping the risk of serving outdated code. Another option is to adjust cache lifetimes directly: keep shorter max-age
values for assets that change often, while giving rarely updated resources a much longer lifetime.
In my own projects, I’ve leaned heavily on bundler-driven content hashing. It’s simple, automatic, and works much like ETags at the HTTP level: whenever the content changes, the identifier changes with it. The result is that caches stay fresh without you having to invalidate anything manually.
Debugging and Testing Caching
One of the hardest parts of caching is that problems are often invisible, the browser quietly serves you an old file and you don’t realize it until something feels “off.” The fastest way to confirm what’s happening is to open your browser’s DevTools and check the Network tab. There, the Size column will reveal whether a file came directly from the server or was pulled from disk or memory cache. You can also toggle the “Disable cache” option to force every request to hit the server, which is invaluable during debugging.
On the command line, tools like curl
or httpie
are equally helpful. Running curl -I https://example.com/app.js
shows only the response headers, letting you verify whether Cache-Control
, Expires
, or ETag
are set the way you expect. Adding an If-None-Match
or If-Modified-Since
header to the request simulates what the browser does during validation, and you’ll know caching is working if the server replies with a simple 304 Not Modified
.
For a higher-level view, performance audits such as Lighthouse or PageSpeed Insights can flag assets that aren’t cached efficiently. And if you’re troubleshooting right after a deployment, don’t forget the basics: a normal refresh may still serve cached files, while a hard refresh forces the browser to fetch fresh copies. It’s a quick sanity check that often clears up “phantom bugs” before you dive deeper.
Debugging rule of thumb: when a bug seems to linger mysteriously, always suspect the cache before the code.
How I Cache in Practice
Over time I’ve settled on a few simple patterns that work well in most front-end projects:
- For static assets like images or fonts, I give them a long
max-age
and serve them under hashed filenames so they can sit safely in cache for months or even years. - For CSS and JavaScript bundles, I rely on hashed filenames too. That lets me use long cache durations while still getting instant invalidation whenever I deploy a new build.
- For HTML, I usually keep caching short, or set
no-cache
, since it often points to fresh bundles that need to be pulled in right away.
And when something strange shows up in production? My first step is almost always to clear the cache and try again, and you’d be surprised how many “mystery bugs” vanish as soon as stale files are out of the way.
Caching is one of the easiest performance wins in web development, but it’s also one of the easiest ways to create subtle bugs. The trick is to be intentional: decide what can stay cached, for how long, and how updates will reach users. Get that balance right, and caching becomes an ally rather than a source of frustration. After all, the fastest network request is the one you never have to make.
Useful Resources
- MDN Web Docs: HTTP caching
- Prevent unnecessary network requests with the HTTP Cache (web.dev)
- Conditional HTTP GET: The fastest requests need no response body
- Cache busting in webpack
This content originally appeared on DEV Community and was authored by Darkø Tasevski