What Is Caching in Web Development? A Complete Guide
Learn every level of web caching: browser cache, Redis server-side caching, CDN caching, database caching, and memoisation. With real code examples, cache invalidation strategies, and the common mistakes that silently hurt production applications.
Caching is one of those topics that every developer knows exists but most do not fully understand until they have debugged a slow application at 2am and traced the problem to thousands of identical database queries running every second. This guide covers every level of caching in web development: what each type actually does, when to reach for it, how to implement it correctly, and the mistakes that bite you in production.
What Is Caching?
Caching is the process of storing a copy of data in a faster or more accessible location so that future requests for the same data can be served without repeating the original work. The underlying insight is simple: if computing or fetching a piece of data is expensive (a slow database query, a slow API call, a complex calculation), store the result temporarily. The next time the same data is needed, return the stored copy instead of doing the work again.
Caching in web development is not a single thing you turn on. It happens at multiple independent layers simultaneously: in the visitor’s browser, on your server, in a dedicated cache service like Redis, at the CDN edge, and inside the database itself. Each layer handles a different kind of data and operates at a different speed. Understanding all five is what separates developers who add caching as an afterthought from developers who design performant systems from the start.
The best code is no code. The best database query is one that never runs because the answer is already in a cache from a moment ago. Caching is not cheating. It is engineering.
Why Caching Matters: The Numbers That Change Everything
Here is a concrete scenario that shows why caching is not optional for any application with meaningful traffic. Suppose you have a product listing page on an e-commerce site. Loading that page requires a database query that takes 200ms. Not terrible in isolation. But under load, the numbers change fast.
The performance difference is not marginal. Effective caching can reduce page load times from seconds to milliseconds. The same physical server can handle ten times the traffic. And crucially: users on the other side of the world experience the same speed as users in your data centre’s city, because the cached response travels from a CDN server nearby.
I have seen this play out repeatedly. A startup launches, the first 100 users are fine, traffic grows, and one day the site crawls to a halt at 8am when everyone checks their notifications. The database is melting under repeated identical queries for data that barely changes. Adding a Redis cache in front of the most expensive queries turns an afternoon of panic into a five-minute fix. The code change is small. The impact is immediate and dramatic.
The Five Levels of Caching in Web Development
Web caching happens at five distinct layers, each independent of the others and each suited to a different type of data. In a well-designed application, all five layers are active simultaneously, each handling the data it is best suited for:
Browser Cache: The Free Performance Win You Are Already Getting
The browser automatically caches static files: images, CSS stylesheets, JavaScript bundles, and web fonts. On a visitor’s first page load, these files are downloaded and stored locally. On every subsequent visit, the browser checks its local cache before making any network request. If the cached copy is still valid, the file loads from local disk at near-zero latency. No network round trip. No server load. Completely free.
You control browser caching behaviour through HTTP response headers. The most important is Cache-Control, which tells the browser how long to keep a cached copy and under what conditions to revalidate with the server.
Cache-Control Headers: How to Tell Browsers What to Cache
Getting your Cache-Control headers right is one of the highest-impact, lowest-effort things you can do for website performance. Here are the three patterns that cover most real-world cases:
| Asset Type | Recommended Cache-Control | Why |
|---|---|---|
| CSS and JS bundles (hashed) | public, max-age=31536000, immutable | Build tools add a content hash to filenames. When the file changes, the URL changes. Cache the old URL forever. |
| Images (hashed or versioned) | public, max-age=31536000, immutable | Same principle as CSS and JS: version in the filename, cache the URL forever. |
| Web fonts | public, max-age=31536000 | Fonts almost never change. Cache them for a year and stop re-downloading them on every visit. |
| HTML pages | public, no-cache | HTML changes with deployments. Revalidate on each request so users never see a cached version of a page that has been updated. |
| User-specific pages | private, no-store | Dashboards, account pages, order history: these belong only to one user and must never be cached by shared infrastructure. |
| API responses (public data) | public, max-age=60, stale-while-revalidate=300 | Short cache for mildly dynamic data. Serve the cached copy immediately, refresh in the background. |
Setting max-age=31536000, immutable on a file named styles.css and then deploying an updated version of that file is a trap. Visitors who have the old version cached will never see the update for a year. The immutable cache pattern only works when your build tool generates a new filename for each build, for example styles.a3f91bc.css becoming styles.7d89c2f.css when the content changes. Vite, webpack, and most modern build tools do this automatically. If your build tool does not version filenames, use no-cache instead of immutable for HTML-linked assets.
Server-Side Caching With Redis: The Most Impactful Cache Layer
Server-side caching stores computed results in memory on your server so they can be returned without hitting the database or re-running expensive logic. Redis is the tool almost every production application uses for this. It is an in-memory key-value store that reads and writes data in under a millisecond. Compared to a 100-200ms database query, this feels instantaneous.
The pattern is the same every time, regardless of language or framework: check the cache first. If the data is there (a cache hit), return it immediately. If it is not (a cache miss), fetch from the database, store the result in Redis with a TTL (time to live), and return the result. Every subsequent request within the TTL window returns the cached copy.
Redis Caching Patterns You Will Actually Use
Cache-aside is the most common Redis caching pattern. But it is not the only one. Here are three patterns that cover the scenarios you encounter in real applications:
Cache keys are strings and they must be unique within your Redis instance. Use a consistent naming convention that prevents collisions and makes debugging easy. A good format is entity:identifier:variant. For example: user:42, product:listing:page:3, order:1234:summary. Namespacing by entity type also lets you delete all keys for a specific entity when the data changes: redis.keys(‘user:*’) returns every user cache entry so you can clear them all after a bulk update.
CDN Cache: Serving Static Assets From the Edge
A CDN (Content Delivery Network) caches your static assets at servers distributed geographically around the world. When a visitor in Singapore requests an image from your site, they receive it from a CDN server in Singapore or nearby, not from your origin server in Frankfurt. The file travels a fraction of the distance, and the page loads significantly faster for that visitor.
CDN caches respect the same Cache-Control headers your origin server sends. Set long cache lifetimes for static assets (images, fonts, hashed CSS and JS files) and shorter lifetimes for content that changes more frequently. Cloudflare’s free tier, Bunny.net, and Amazon CloudFront all operate on this model.
The practical rule: anything with a content hash in its filename gets a one-year cache. Everything else gets a shorter TTL or no-cache so the CDN revalidates freshness before serving. When you deploy new static files, the changed hash in the filename means the CDN treats them as new assets entirely, no manual cache purging needed.
Database Cache: The Cache Layer You Already Have
Every major database (PostgreSQL, MySQL, MongoDB) maintains its own internal caches that you benefit from without any configuration. Understanding them helps you interpret performance differences and make better decisions about schema and query design.
The buffer pool (or shared buffers in PostgreSQL) is the database’s main memory cache. It stores table data and index pages in RAM so that frequently accessed rows do not require disk reads. When your database has enough memory allocated and your working data fits within it, reads can be served entirely from RAM, which is orders of magnitude faster than disk. This is why allocating more RAM to your database server often improves performance dramatically even without changing a single query.
Plan caching means the database stores and reuses the execution plan for parameterised queries rather than replanning them from scratch on each execution. This is one of the reasons parameterised queries (WHERE id = $1) perform better than string-interpolated queries (WHERE id = 42) at high volume: the parameterised version reuses the cached plan, the interpolated version may trigger a new parse and plan cycle.
You rarely configure database caches directly, but you influence them by keeping your working dataset small (proper indexing, archiving old data), allocating sufficient RAM to the database process, and designing queries that access data in predictable, cache-friendly patterns.
Memoisation: Application-Level Caching in Your Own Code
Memoisation is caching at the function level. If a function is called with the same inputs multiple times, memoisation stores the result of the first call and returns the stored copy on subsequent calls with identical inputs, without re-executing the function body. This is the simplest form of caching because it happens entirely within your application’s process memory, with no external dependencies.
Memoisation is ideal for pure functions that are called repeatedly with the same inputs: formatting dates, computing totals, parsing configuration, or generating slugs from the same source strings. It is not suitable for functions whose results change over time or that depend on external state, because the cached result will be stale after the external state changes.
Cache Invalidation: The Hardest Problem in Caching
There is a well-known joke in software development that there are only two hard problems in computer science: cache invalidation and naming things. It is funny because it is accurate. Caching data is easy. Knowing when cached data has become stale and needs to be refreshed is genuinely hard, and getting it wrong causes some of the nastiest bugs in production: users seeing outdated prices, old profile pictures, stale inventory counts, or wrong order statuses.
There are three main strategies for managing cache invalidation, each with different trade-offs:
A cache stampede happens when a popular cache key expires and many simultaneous requests all find an empty cache and hit the database at the same time. If your most popular product page is cached for 60 seconds, and 500 concurrent users all land on the page in the same second the cache expires, you just sent 500 simultaneous identical database queries. The database falls over. The page goes down. The fix: use a mutex (lock) so only one request populates the cache while others wait, or use probabilistic early expiration to refresh the cache before it expires for a subset of requests rather than letting all requests stampede through at once.
The Caching Mistakes That Hurt Real Applications
These are the mistakes I have seen repeatedly in production codebases. Each one either prevents caching from helping at all or creates new problems that are harder to debug than the original performance issue:
Storing a response containing a user’s personal information in a shared cache (CDN or public Redis) and then serving it to other users. This is a data leak. It does happen, especially when caching middleware is added globally without per-route configuration.
Caching product prices, inventory counts, or user balances for hours. A customer buys the last item in stock. The next 500 visitors see “In Stock” for three hours from cache, place orders, and trigger fulfilment chaos.
Running the same heavy aggregation query (total orders this month, user activity summary, dashboard metrics) on every page load for every user. These queries often take 1-5 seconds and the result barely changes between requests.
Two different queries or features using an identical cache key. User A’s data overwrites User B’s cache, or a product query overwrites a user query. Subtle bugs that only appear under concurrent load.
Redis goes down (it does happen) and your application crashes because the cache check throws an uncaught error. Now your application is down because its cache is unavailable, not because its database is unavailable.
Writing to the cache inside a database transaction before the transaction commits. If the transaction rolls back, the cache now contains data that was never persisted to the database.
7-Step Guide to Building a Caching Strategy for Your Application
Adding caching to an application randomly is how you end up with a cache that provides false confidence without real benefit. Here is the systematic approach that actually works:
- Measure before you cache anything. You cannot improve what you have not measured. Use your database’s slow query log, APM tools (Datadog, New Relic), or simply log query execution times in your application. Find the top five slowest or most frequently executed queries. These are your caching targets. Caching fast queries provides little benefit. Caching the slow ones makes the application feel like a different product.
- Set correct Cache-Control headers on all static assets before adding any server-side caching. Audit your static asset headers. Hashed CSS and JS files should have max-age=31536000, immutable. HTML files should have no-cache. User-specific pages should have private, no-store. This is a ten-minute change that immediately reduces bandwidth, server load, and load times for returning visitors at zero implementation cost.
- Add Redis (or Memcached) and start with the cache-aside pattern for your most expensive queries. Install Redis, connect it to your application, and wrap your top two or three slow queries with the check-cache-first pattern shown earlier in this guide. Start with a conservative TTL (60 to 300 seconds). Measure the before and after: check your query count in the database, check your API response times. The difference will make the rest of the caching work feel motivated.
- Implement cache invalidation for any data your users write and expect to see updated immediately. If a user updates their profile photo, they should see the new photo immediately, not the cached old one. Identify every write operation in your application and decide: does this require immediate cache invalidation, or is a short TTL sufficient? Invalidate cache keys explicitly on writes for user-facing data. Allow TTL-based expiry for background data and aggregates.
- Enable CDN caching for static assets. If you have not already done so, put your site behind Cloudflare (free tier) or another CDN. Verify that your static files are being served from cache by checking the CF-Cache-Status: HIT header in browser DevTools. International visitors will notice the difference immediately. Your origin server load drops proportionally to your cache hit ratio.
- Make every cache operation safe to fail. Wrap every Redis get and set call in a try/catch block. Log failures but do not let them crash the application. Test this by temporarily stopping Redis and confirming your application falls back to the database correctly, just more slowly. Cache availability should improve performance, not determine whether the application works at all.
- Monitor cache hit rates and set up alerts for sustained cache misses. A healthy cache hit rate for user-facing data is above 80%. Below 50% often means cache keys are not matching correctly, TTLs are too short, or you are generating unique keys for data that should share a cache entry. Add a monitoring dashboard showing hit rate, miss rate, eviction count, and memory usage. A sudden drop in hit rate is often an early signal of a bug or a deployment issue before it becomes a user-visible problem.
Frequently Asked Questions About Web Caching
They serve completely different purposes and you will usually want both. Browser caching stores static files on the visitor’s device so they do not get re-downloaded on subsequent visits. It is controlled by HTTP headers and handles images, CSS, JavaScript, and fonts. Redis caching stores computed results and database query results on your server so you do not have to repeat expensive database work. Use browser caching for static assets and Redis caching for dynamic data that requires database access. The two layers complement each other: a page might load its JavaScript from browser cache (no server contact) while the personalised data on that page is served from Redis (no database contact).
Match the TTL to how often the data changes and how much staleness your users can tolerate. A product’s description might change once a month: a one-hour TTL is fine. A product’s stock count might change every few minutes: a 30-second TTL or explicit invalidation on purchase is more appropriate. User profile data changes rarely: a 10-minute TTL is reasonable. Session data might need to stay current: explicit cache invalidation on any session update is better than a TTL. Start with shorter TTLs and extend them based on observation. It is much easier to extend a TTL that is making things unnecessarily slow than to shorten one that has been causing users to see stale data.
Both are in-memory key-value stores used for caching, but Redis has become the clear default choice for most applications because it does significantly more. Memcached is a pure cache: it stores strings and nothing else. Redis supports strings, lists, sets, sorted sets, hashes, and more. Redis also persists data to disk (Memcached does not), supports pub/sub messaging, provides atomic operations on complex data structures, and can be used as a full message queue alongside a cache. For new applications, choose Redis. The only situation where Memcached might be preferred is when you need multi-threaded cache writes at extreme scale and are already invested in Memcached infrastructure, since Redis uses a single-threaded event loop for writes.
The pattern in Python is identical to Node.js: check the cache first, fall through to the database on a miss, and store the result with a TTL. Using the redis-py library: import redis; r = redis.Redis(). Then in your data access function: cached = r.get(cache_key). If cached is not None, return json.loads(cached). Otherwise query the database, call r.setex(cache_key, ttl_seconds, json.dumps(result)), and return the result. For Django applications, the built-in cache framework with a Redis backend (using django-redis) provides a higher-level cache API with automatic serialisation. For FastAPI or Flask, redis-py directly or the cachetools library for in-process memoisation are common choices.
Yes, in most cases. Third-party API calls are often the slowest operations in a web application: network latency, rate limits, and external service performance are all outside your control. Caching API responses protects you from all three. A weather API response cached for 15 minutes means 1 API call every 15 minutes instead of one per user request. A currency conversion rate cached for an hour means one external call per hour instead of hundreds. The TTL you choose depends on how frequently the data changes: exchange rates can cache for minutes, a restaurant’s menu might cache for hours. Always respect the third-party service’s terms of service regarding data freshness, and never cache responses that contain user-specific data from authenticated API endpoints.
The standard approach is to use a consistent key naming convention with a namespace prefix. If all user-related cache keys follow the format user:{id}:*, you can find them all with redis.keys(‘user:*’) and delete them all with redis.del(…keys). However, KEYS is a blocking command that scans the entire keyspace and should not be used in production on large Redis instances. The production-safe alternative is SCAN with a cursor, which iterates through keys in small batches without blocking. Another approach is to use Redis keyspace notifications to automatically invalidate related cache keys when specific keys expire or are deleted. For simple applications, a cache namespace prefix with a version number is the cleanest solution: increment the version prefix when you want to invalidate all keys for an entity, and old keys expire naturally via their TTL.
Tools for debugging and working with cached data
Format JSON from Redis, compare API responses before and after caching, convert data between formats, and more. All free, all in your browser, no login required.
Cache the Right Things, Invalidate Them Correctly, and Your Application Feels Like a Different Product
Caching is not complicated in principle. The mental model is simple: expensive operations run once, results get stored, subsequent requests get the stored version. What makes caching genuinely hard is the edge cases: cache invalidation timing, data that is personal to a user, systems that break when the cache layer goes down, and stampedes when popular cache entries expire under load.
Start with the browser cache: set Cache-Control headers correctly on all your static assets. That takes an hour and immediately benefits every visitor. Then add Redis in front of your slowest database queries. Then verify your CDN is caching and serving from edge servers closest to your users. Each layer addresses a different problem and the layers compound: a user hitting your application goes through browser cache, CDN, Redis, and only reaches your database for the rare requests that miss all three layers.
When debugging cached data, the JSON Formatter helps you inspect what is actually stored in Redis. The Text Diff Checker helps you compare a cached response against the live database response to spot staleness or invalidation bugs. Both tools are free and work directly in your browser.