A Practical Caching Playbook



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:

  1. Browser requests a resource.
  2. Browser checks its cache for a valid copy.
  3. If it finds one, it serves it immediately.
  4. 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 methodGET requests are typically cacheable, while POST, PUT, and DELETE are not.
  • The Vary header – if present, it tells the cache to consider certain request headers (like Accept-Encoding or User-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 by Cache-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


This content originally appeared on DEV Community and was authored by Darkø Tasevski