What you'll learn
By the end of this you'll understand why caching is the single highest-leverage optimization in most production systems, when to use each caching strategy, where the traps are, and how to think about cache invalidation without going insane. You'll also have a clear framework for deciding what to cache, where to put it, and how long to keep it around.
This isn't a Redis tutorial or a CDN how-to. It's the mental model behind why caching works, so you can apply it to any layer of your stack — and so you don't accidentally build a system that's fast but wrong.
Who this is for
- Backend engineers who've heard "just add a cache" without a clear explanation of what that actually means in a real production system
- Developers preparing for system design interviews who want to understand caching beyond "Redis is fast"
- Anyone who has hit a slow database query in production and wants a framework for thinking about the fix
You can skip this if you already know the difference between cache-aside and write-through and have implemented distributed cache invalidation across services. The fundamentals here are the part most engineers already internalized the hard way through painful production incidents.
What is caching?
Caching means storing a copy of an expensive result somewhere fast so you can serve it again without recomputing it. The database query you ran once, the API response you fetched, the leaderboard you calculated — if the answer won't change before the next request, keep a copy close to the caller.
Plain English: Caching is keeping a shortcut to an answer you've already figured out. Instead of asking the database every time someone loads a product page, you store the product data in Redis and serve it from there. The first request is slow; the next thousand are fast.
Simple idea: read from the fast store first. If the answer isn't there, get it from the slow store, save a copy in the fast store, and return it. That's cache-aside in one sentence.
Prerequisites
- Basic familiarity with HTTP APIs and a relational database (PostgreSQL or similar)
- A rough sense of where latency comes from in a web request (network, database, compute)
- Optional but helpful: Redis or Memcached running locally for the working examples
Setup from zero
Step 1 — Identify what's slow and why
Before adding a cache, measure exactly what you're trying to speed up. Run your slow query in the database directly with EXPLAIN ANALYZE and look at the execution plan. Profile your API endpoint with realistic data. Know your baseline response time before you touch anything.
The most common candidates for application-level caching:
- Database queries that are slow and whose results change infrequently — leaderboards, aggregations, category listings, dashboard summaries
- External API calls — third-party services with rate limits, high latency, or per-call pricing
- Computed results — anything you calculate from raw data that doesn't change on every request
One important caveat: if your query is slow because of a missing index, add the index first. A cache on a poorly indexed query is technical debt that will follow you forever. The cache hides the problem and you stop feeling the pressure to fix it. Index first, cache second.
Step 2 — Choose a caching strategy
Cache-aside (lazy loading) is the most common pattern and the right default for most web applications. Your app checks the cache first. If the value is there (a cache hit), return it. If it's not (a cache miss), fetch from the source, store the result in the cache, then return it to the caller.
Write-through writes to the cache and the database simultaneously on every write operation. The cache is always warm and always current. The cost: every write pays a double write, and you end up caching data that might never actually be read.
Write-behind (write-back) writes to the cache immediately and flushes to the database asynchronously on a delay. Very fast writes, but you risk data loss if the cache layer fails before flushing to disk. Don't use it for financial transactions or anything you can't afford to lose.
For most web application backends, cache-aside is the right starting point.
Step 3 — Set meaningful TTLs and plan invalidation
Every cache entry should have a time-to-live (TTL). When the TTL expires, the entry is evicted and the next request triggers a fresh fetch from the source. TTL is your escape hatch when you can't know in advance exactly when upstream data will change.
Common TTL ranges by data type:
- Session data: 30 minutes to 2 hours
- User profile information: 5–15 minutes
- Category or navigation data: 1–24 hours
- Static reference data (country lists, config): 24 hours or more
- Real-time data like inventory counts or prices: 10–60 seconds
Active invalidation means you explicitly delete or update a cache entry the moment the underlying data changes. Example: when a user updates their profile, you immediately delete cache:user:123. The next read triggers a fresh fetch and re-populates the cache.
> Little tip: Don't use the same TTL for everything. A user's payment method is different from a static navigation menu. Treat the data differently because the cost of being wrong is completely different.
The mental model
The mental model for caching is distance from truth.
Every cached value is a copy at some distance from the source of truth — the database, the upstream API, the computed function result. Short distance (short TTL, active invalidation) means your copy is nearly always correct. Long distance (long TTL, no active invalidation) means the copy can drift significantly from reality.
Your job as the engineer is to pick the right distance for each type of data based on the cost of being wrong. For prices and inventory, the distance should be very short — seconds at most. For a blog post body, moderate distance is perfectly fine. For a country list, long distance is acceptable because that data almost never changes.
Caches don't make your system correct — they make it fast. Correctness remains your responsibility, and it comes from being intentional about how far from truth you're willing to operate for each specific piece of data.
Key terms
Cache hit — the cache has the value you requested. Fast path. No database involved.
Cache miss — the cache doesn't have the value. Slow path — fetch from the source and populate the cache for next time.
TTL (time-to-live) — how long a cache entry persists before it is automatically evicted.
Cache invalidation — explicitly removing or updating a cache entry when the underlying source data changes.
Cache-aside — check cache first, fall back to the source on a miss, write the result to cache before returning.
Write-through — write to both cache and database simultaneously on every write. Cache is always current.
Write-behind — write to cache immediately, flush to database asynchronously later. Fast writes, small risk of data loss.
Cache stampede (thundering herd) — many requests simultaneously miss a popular cache entry and all attempt to rebuild it from the source, overwhelming the database.
Step-by-step: cache-aside with Redis
Here's a minimal cache-aside implementation for a user profile lookup:
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const TTL_SECONDS = 300; // 5 minutes
async function getUserProfile(userId: string) {
const cacheKey = `user:${userId}`;
// 1. Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. Cache miss — fetch from database
const user = await db.users.findById(userId);
if (!user) return null;
// 3. Store in cache with TTL
await redis.set(cacheKey, JSON.stringify(user), "EX", TTL_SECONDS);
return user;
}
When the user updates their profile, invalidate explicitly:
async function updateUserProfile(userId: string, data: ProfileUpdate) {
await db.users.update(userId, data);
await redis.del(`user:${userId}`); // active invalidation
}
Working examples
Cache-aside flow:
request → check cache
hit → return cached value (fast path)
miss → fetch from DB → write to cache → return value (slow path)
Active invalidation flow:
user updates profile → DELETE cache:user:123 → next read rebuilds cache entry
Stampede mitigation: the first miss acquires a short-lived distributed lock and rebuilds the cache entry; subsequent misses wait or retry instead of all hitting the database at once.
Patterns / when to use each strategy
Use cache-aside when: reads dominate writes, you only want to cache what's actually requested, and cold starts are acceptable. This covers most web APIs.
Use write-through when: reads are extremely predictable, you need the cache always warm, and write volume is low enough that double writes aren't a bottleneck.
Use write-behind when: write throughput is extreme and a small window of potential data loss is acceptable — event counters, analytics ingest, session activity tracking.
Use pub/sub invalidation when: multiple services independently cache the same data and you need them all to drop stale copies when the source changes.
> Little tip: If you're caching something that will receive synchronized traffic spikes — a homepage for an event site, a leaderboard that resets every hour — stampede prevention isn't optional. Build it in from the start.
Common mistakes
Caching before fixing the query — a missing index makes every request slow. Caching masks the problem instead of fixing it. Run EXPLAIN ANALYZE first.
Same TTL for all data types — user payment methods and static country lists have completely different freshness requirements. One-size-fits-all TTLs cause either unnecessary database load or stale data bugs.
No invalidation strategy — relying only on TTL means users see stale data for the entire TTL window after an update. For profile or settings data, active invalidation on write is cheap and correct.
Caching user-specific data at the CDN without private headers — one user's dashboard cached and served to another user is a security incident, not a performance win.
Troubleshooting
Cache hit rate is near zero — check that your cache key includes all dimensions that distinguish responses (user ID, locale, version). A key that's too broad causes collisions; a key that's too narrow causes misses.
Users see stale data after updates — you're relying on TTL only. Add active invalidation on write paths, or shorten TTL for data that users expect to change immediately.
Database spikes when a popular key expires — classic cache stampede. Add probabilistic early expiration or a distributed lock around rebuild logic.
Memory usage grows without bound — set a max memory policy (Redis maxmemory + allkeys-lru) and ensure every key has a TTL. Keys without TTL never expire.
Checklist
- [ ] Measured baseline latency before adding a cache
- [ ] Fixed missing indexes on slow queries before caching them
- [ ] Chose cache-aside as the default unless a specific pattern requires otherwise
- [ ] Set TTL per data type based on freshness requirements
- [ ] Implemented active invalidation on write paths for user-facing data
- [ ] Added stampede protection for high-traffic cache keys
- [ ] Configured eviction policy and max memory limits on the cache store
- [ ] Verified cache keys don't leak data across users or tenants
Practice task
Pick one slow API endpoint in a project you have access to. Measure its p95 latency with realistic traffic. Identify the underlying query or computation. Implement cache-aside with a 5-minute TTL and active invalidation on the write path. Deploy to staging, run the same traffic pattern, and compare p95 before and after. Document your cache key design and explain why you chose that TTL.
FAQ
Should I cache at the application layer or use a CDN?
Both serve different purposes. Application caches (Redis) store computed or database-backed results. CDNs cache HTTP responses closer to users. Static assets and public API responses belong at the CDN. User-specific or frequently changing data belongs in an application cache.
How do I invalidate cache across multiple services?
Publish an invalidation event to a message bus (Redis pub/sub, SNS, Kafka) when data changes. Each service that caches that data type subscribes and deletes its local cache entry on receipt. Alternatively, accept bounded staleness via TTL if the data type tolerates it.
Is a longer TTL always better for performance?
Longer TTL means higher hit rates and lower origin load, but also longer windows of stale data. Match TTL to how wrong the data can be without causing user-visible problems.
What to learn next
With caching fundamentals solid: rate limiting to protect your origin when cache misses spike, message queues to decouple cache rebuild work from request paths, and CDN configuration for static and public dynamic content at the edge.
Related on Baseline
- System design: rate limiting — protect your API when cache misses or traffic spikes overwhelm the origin
- System design: queues — decouple expensive cache rebuilds from synchronous request handling
- System design: CDN and edge — move static and public content closer to users at the network layer
Takeaways
Caching is the highest-leverage optimization in most production systems, but only when you match strategy, TTL, and invalidation to each data type's freshness requirements. Cache-aside is the right default. Active invalidation on write paths keeps user-facing data correct. Stampede prevention is non-negotiable for popular keys.
If you remember only one thing: a cache is a copy at some distance from truth — pick that distance deliberately for each type of data, because speed without correctness is just a faster way to serve wrong answers.