Caching is one of the most effective tools for optimizing the performance of web applications. Properly configured caching can reduce page load times several times over, lower server load, and reduce users’ data consumption. At the same time, caching remains one of the least understood areas of web development, where poor decisions can result in stale content being shown or, conversely, no caching at all where it is needed.
In this article, we’ll look at the different levels of caching, from the user’s browser to the server infrastructure, and discuss strategies suited to different types of content.
The nature of the caching problem
Caching seems like a simple idea: save the result of an expensive operation and reuse it instead of calculating it again. But behind this simplicity lies a fundamental challenge—the cache invalidation problem. How do you know when stored data has become stale? How do you ensure that all users see an update? How do you balance data freshness against resource savings?
A famous saying goes that there are only two hard problems in computer science: cache invalidation and naming things. Behind the humor is a reality: caching requires a deep understanding of data lifecycles and the trade-offs between different requirements.
Different types of content require different strategies. Static resources such as JavaScript bundles or images can be cached aggressively for a long time because the filename changes when the content changes. Dynamic content such as a news feed requires a more nuanced approach that balances freshness and performance. Personalized content such as a shopping cart should not be cached at shared levels at all.
HTTP caching in the browser
The browser’s HTTP cache is the first line of defense in a caching system. When a browser requests a resource, the server can use HTTP headers to specify how long that resource can be stored and under what conditions it can be served from the cache.
The Cache-Control header is the primary mechanism for controlling caching. The max-age directive specifies the resource’s lifetime in seconds. During this time, the browser uses the cached version without contacting the server. For static resources with a hash in the filename, it makes sense to set a high max-age value—one year or even longer. The file will change only if its content changes, in which case its filename will change too.
Contrary to what its name might suggest, the no-cache directive does not mean that caching is disabled. It specifies that the resource can be cached, but that the browser must check with the server to confirm that the cached version is still current before using it. This is useful for HTML documents: the page is cached, but the browser checks whether it has changed with each request.
The no-store directive prohibits caching entirely. This is necessary for confidential data that must not be saved to the user’s disk: personal information, banking data, and the results of authenticated requests.
The private directive specifies that a resource is intended for a specific user only and must not be cached by intermediate proxies or CDNs. This is important for personalized content—a CDN may serve thousands of users, and caching one user’s personal data could lead to an information leak.
Conditional requests and validation
When the lifetime of a cached resource has expired, or when the no-cache directive is used, the browser does not necessarily have to download the resource again. The conditional request mechanism makes it possible to check whether the resource has changed since it was last retrieved and, if not, use the cached version.
When a resource is requested for the first time, the server can include an ETag header in its response with a unique identifier for the content version, or a Last-Modified header with the date it was last changed. The browser stores these values along with the cached resource.
On a subsequent request that requires validation, the browser sends a conditional request with an If-None-Match header containing the stored ETag, or an If-Modified-Since header with the stored date. The server checks whether these values match the resource’s current state. If they do, the resource has not changed, so the server responds with status code 304 Not Modified, without a response body, and the browser uses the cached version. If they do not match, the server sends a new version of the resource.
This mechanism is especially useful for large resources. Instead of downloading a file several megabytes in size again, the exchange consists of a few hundred bytes of headers. A network request still takes place, adding latency, but bandwidth and download time are saved.
CDN and edge caching
A Content Delivery Network adds another layer of caching between the user’s browser and the application’s origin server. CDN servers are distributed geographically around the world, and a user’s request is handled by the server nearest to them.
On the first request for a resource, the CDN contacts the origin server, receives the response, caches it, and delivers it to the user. Subsequent requests for the same resource from other users are served from the CDN cache without contacting the origin server. This reduces the load on the main servers and lowers latency for users.
Configuring caching on a CDN requires understanding which content can be shared among all users and which is personalized. Public resources—static assets, public pages, and API responses without personalization—are good candidates for edge caching. Private content should pass through the CDN without being cached.
Vary headers play an important role in how CDNs work. They indicate that a response depends on specific request headers, and that the CDN should store separate cached versions for different values of those headers. A typical example is Vary: Accept-Encoding, which indicates that different versions of a resource exist for different compression algorithms.
Cache purging is a mechanism for forcibly invalidating cached content on a CDN. When content changes and the new version needs to be shown to all users immediately, an administrator sends a command to clear the cache. Most CDNs provide an API for programmatic purging, making it possible to automate invalidation during deployment.
Service Workers and offline caching
Service Workers give developers programmatic control over browser network requests. This makes it possible to implement complex caching strategies that are not available through HTTP headers.
The cache-first strategy is suitable for resources that rarely change. A Service Worker checks the cache first and, if the resource is found, returns it immediately. If not, it fetches the resource from the network and saves it to the cache for future requests. This strategy provides maximum speed for cached resources.
The network-first strategy is suitable for content where freshness matters. A Service Worker first tries to fetch the resource from the network. If the network is unavailable or the request fails, it falls back to the cached version. This is a good choice for API data where fresh data is preferred, but offline access is also possible.
The stale-while-revalidate strategy offers a compromise between speed and freshness. A Service Worker immediately returns the cached version of a resource for a fast response, while simultaneously fetching an update from the network. The update is saved to the cache for subsequent requests. Users may see slightly stale data, but they will see it instantly.
Service Workers also make it possible to implement a full offline mode for an application. On the first visit, all necessary resources are downloaded and cached. On subsequent visits, the application remains functional even without a network connection—this is the foundation of Progressive Web Apps.
Server-side caching
There are many levels of caching on the server side, each with its own characteristics and use cases.
Application-level caching involves saving computed results in the process’s memory or in an external store such as Redis or Memcached. Results of expensive computations, frequent database queries, and responses from external APIs are all candidates for application-level caching.
Redis has become the de facto standard for server-side caching thanks to its speed, flexibility, and rich set of data structures. It can serve as a simple key-value store for a cache, a message queue, or a session store. Cluster mode provides scalability and fault tolerance.
Database-level caching includes the buffer pool for data and the query cache. PostgreSQL, MySQL, and other DBMSs cache frequently used data in memory themselves. Properly configuring these mechanisms and providing enough memory are critical for performance.
Caching entire pages at the web server or reverse proxy level is effective for content that is the same for all users. Nginx, Varnish, and other solutions can cache generated pages, avoiding requests to the application server for subsequent requests.
Invalidation strategies
Invalidation—removing stale data from the cache—is a critical part of any caching strategy. Poor invalidation leads either to stale content being shown or to excessive load from updates that happen too frequently.
Time-based invalidation is based on the lifetime of cached data. Once the specified period expires, the data is considered stale and is removed or updated. This is a simple approach, but it does not account for actual data changes—the cache may become stale before the timeout or remain current for much longer.
Event-based invalidation responds to events that change data. When a user edits their profile, the profile cache is cleared. When a new article is published, the article list cache is updated. This keeps data fresh, but requires careful implementation for all events that affect cached content.
Versioning resources with hashes or version numbers in filenames is an elegant solution for static resources. The file app.a1b2c3d4.js can be cached forever because when its content changes, its hash—and therefore its filename—changes. Browsers will never receive a stale version; they will simply request the file under its new name.
Monitoring and debugging
Caching affects application behavior in ways that may not be obvious during development. Cache monitoring is necessary to understand its effectiveness and identify problems.
Hit rate—the percentage of requests served from the cache—is a key metric. A high hit rate means the cache is working effectively. A low hit rate may indicate a poor strategy, overly aggressive invalidation, or patterns in how data is accessed.
Cache size and eviction rate indicate whether enough memory has been allocated to the cache. If data is frequently evicted due to a lack of space, increasing the cache size can significantly improve the hit rate.
Latency with and without caching makes it possible to measure the actual performance gain from caching. The difference should be significant; if it is not, caching may not be applied where the performance bottleneck is.
Browser developer tools show which resources were loaded from the cache and which caching headers are set. This is invaluable when debugging client-side caching issues.
Conclusion
Caching is not a single solution, but a multi-layered system in which each level has its own characteristics and use cases. The browser cache, CDN, Service Workers, and server-side cache must all work together to achieve optimal performance.
The key to effective caching is understanding the characteristics of the content. Static resources are cached aggressively for a long time. Dynamic public content requires balancing freshness and performance. Personalized content is cached only at private levels or not cached at all.
Don't forget monitoring: caching without metrics is a shot in the dark. Measure the hit rate, monitor cache size, and compare performance with and without the cache. And remember, the best request is the one that was never made. Use reChecker to analyze caching headers and check whether your web resources are configured correctly.