Lecture 16: Application Caching with Redis¶
HTTP caching (Lecture 15) protects your server from repeated identical requests. But most real applications need to cache things HTTP caching can't touch: the result of an expensive database aggregation, a user's session, a rate-limit counter, a leaderboard. This lecture introduces Redis, the tool most production systems reach for to solve exactly these problems, and the patterns for using it correctly.
In This Lecture¶
- Understand Redis as an in-memory data store and its core data structures.
- Apply the cache-aside, read-through, and write-through/write-behind caching patterns.
- Manage cache invalidation, TTLs, eviction policies, and stampede protection.
- Use Redis for API response caching, session storage, rate limiting, and leaderboards.
What Is Redis?¶
Redis (REmote DIctionary Server) is an open-source, in-memory data store — it keeps its dataset in RAM rather than on disk, which makes reads and writes extremely fast (typically sub-millisecond), at the cost of needing enough memory to hold your working set. Unlike a plain key-value cache, Redis supports several rich data structures natively, which is what makes it useful for far more than "store this value under this key."
| Data structure | What it is | Typical use |
|---|---|---|
| String | A binary-safe sequence of bytes (text, JSON, a counter) | Cached values, counters, flags |
| Hash | A field-value map, like a small object | A user profile or session, without needing multiple keys |
| List | An ordered collection, efficient at both ends | Queues, recent-activity feeds |
| Set | An unordered collection of unique values | Tags, unique visitor tracking, set operations (union/intersect) |
| Sorted Set (ZSet) | A set where every member has a numeric score, kept in order | Leaderboards, rate limiting, priority queues |
# A quick tour of the core data structures, using redis-cli
SET user:42:name "Ayesha" # String
GET user:42:name
HSET user:42 name "Ayesha" plan "pro" # Hash
HGET user:42 plan
LPUSH recent:views "product:101" # List (push to the left/front)
LRANGE recent:views 0 9 # Get the 10 most recent
SADD tags:post:7 "redis" "caching" "backend" # Set
SISMEMBER tags:post:7 "redis"
ZADD leaderboard 1500 "playerA" # Sorted set (score, member)
ZADD leaderboard 2100 "playerB"
ZREVRANGE leaderboard 0 2 WITHSCORES # Top 3, highest score first
Redis is single-threaded for command execution
Redis processes commands one at a time on a single main thread, which sounds
counter-intuitive for a high-performance system — but because everything lives in RAM
and operations are simple, this avoids locking overhead entirely and Redis still
handles hundreds of thousands of operations per second. It also means a single slow
command (like an unbounded KEYS * scan on a huge dataset) can briefly block every
other client — prefer SCAN for iterating keys in production.
Caching Patterns¶
There are three well-established patterns for keeping a cache and a source of truth (a database) in sync. Which one you choose affects read latency, write latency, and how tolerant your system is of stale data.
Cache-Aside (Lazy Loading)¶
Cache-aside (also called lazy loading) is the most common pattern: the application code is responsible for checking the cache first, and only falling back to the database — then populating the cache — on a miss.
sequenceDiagram
participant App
participant Cache as Redis
participant DB as Database
App->>Cache: GET product:42
alt Cache hit
Cache-->>App: cached value
else Cache miss
Cache-->>App: (nil)
App->>DB: SELECT * FROM products WHERE id=42
DB-->>App: row
App->>Cache: SET product:42 (value, TTL)
Note over App: return value to caller
end
async function getProduct(id) {
const cacheKey = `product:${id}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const product = await db.products.findById(id);
// Cache for 5 minutes; JSON.stringify because Redis strings are text/bytes.
await redis.set(cacheKey, JSON.stringify(product), 'EX', 300);
return product;
}
Cache-aside is simple and resilient — if Redis goes down entirely, the app still works (just slower, hitting the database for everything), because the application code guards every access. Its main drawback is the first request after an expiry always pays the full database cost.
Read-Through¶
In a read-through cache, the caching layer itself (not your application code) is responsible for loading from the database on a miss — the application only ever talks to the cache, which transparently proxies to the database when needed. This centralizes the loading logic (often via a caching library or a dedicated caching service) instead of repeating it at every call site, but it requires the cache to know how to reach your data source. With plain Redis, this pattern is usually implemented via a thin wrapper/library rather than a Redis feature itself.
Write-Through and Write-Behind¶
- Write-through: every write goes to the cache and the database synchronously, as part of the same operation, so the cache is never stale after a write. Simpler to reason about, but every write pays the latency of both systems.
- Write-behind (write-back): the write goes to the cache immediately, and is persisted to the database asynchronously (batched, on a delay). This is faster for the caller, but risks data loss if the cache crashes before the write is flushed to the database — use it only when that risk is acceptable (e.g., high-frequency metrics) and not for critical transactional data.
| Pattern | Read path | Write path | Risk |
|---|---|---|---|
| Cache-aside | App checks cache, then DB on miss | App writes DB, then invalidates/updates cache | Brief staleness after write if not handled carefully |
| Read-through | App only talks to cache; cache loads DB on miss | Usually paired with write-through | Adds a layer between app and DB |
| Write-through | — | App writes to cache and DB together, synchronously | Higher write latency |
| Write-behind | — | App writes to cache; DB updated asynchronously | Possible data loss on cache failure |
Cache Invalidation, TTL, and Eviction Policies¶
TTL (Time to Live)¶
A TTL is the number of seconds a key is allowed to live before Redis automatically expires (deletes) it. Nearly every cached value should have one — an unbounded cache is a memory leak waiting to happen.
await redis.set('session:abc123', sessionData, 'EX', 3600); // expires in 1 hour
await redis.expire('product:42', 300); // set/refresh TTL on existing key
await redis.ttl('product:42'); // check remaining seconds
Eviction Policies¶
Redis has a configurable maxmemory limit, and when it's reached, an eviction policy decides which keys to remove to make room for new writes:
| Policy | Behavior |
|---|---|
noeviction |
Reject new writes with an error once memory is full — no data is silently lost. |
allkeys-lru |
Evict the **L**east **R**ecently **U**sed key, across all keys. Most common choice for a pure cache. |
volatile-lru |
LRU eviction, but only among keys that have a TTL set — keys without a TTL are treated as permanent and never evicted this way. |
allkeys-lfu |
Evict the **L**east **F**requently **U**sed key — better than LRU when some keys are accessed rarely but recently. |
volatile-ttl |
Evict the key with the shortest remaining TTL first. |
Choosing a policy
For a cache-only Redis instance (nothing stored that you can't regenerate from the
database), allkeys-lru is a safe, common default. If the same Redis instance also
holds data with no natural source of truth (like session data with no TTL you'd be sad
to lose), use volatile-lru so those permanent keys are protected from eviction.
Cache Stampede Protection¶
A cache stampede (also called a "dog-pile" or "thundering herd") happens when a popular, expensive-to-compute key expires, and a burst of concurrent requests all miss the cache simultaneously — sending a flood of identical, expensive queries to the database at once, sometimes taking it down entirely.
Two common mitigations:
- Locking/single-flight: the first request to miss acquires a short-lived lock and recomputes the value; concurrent requests wait briefly (or serve a stale value) instead of all hitting the database independently.
- Early/probabilistic expiration: recompute the value slightly before it actually expires, with a small random jitter per key, so many keys with the same nominal TTL don't all expire at the exact same instant.
async function getWithStampedeProtection(key, loader, ttlSeconds) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// NX = only set if not already set; acts as a short-lived lock so only one
// process recomputes the value while others can retry or serve stale data.
const lockAcquired = await redis.set(`lock:${key}`, '1', 'EX', 10, 'NX');
if (!lockAcquired) {
await new Promise((r) => setTimeout(r, 50));
return getWithStampedeProtection(key, loader, ttlSeconds); // brief retry
}
try {
const value = await loader();
await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
return value;
} finally {
await redis.del(`lock:${key}`);
}
}
Real-World Uses of Redis¶
API Response Caching¶
Applying cache-aside to a whole API response (not just a single database row) can turn an expensive, multi-query endpoint into a single fast lookup for repeat requests:
app.get('/api/dashboard-stats', async (req, res) => {
const cacheKey = 'dashboard-stats';
const cached = await redis.get(cacheKey);
if (cached) return res.json(JSON.parse(cached));
const stats = await computeExpensiveDashboardStats(); // several joins/aggregations
await redis.set(cacheKey, JSON.stringify(stats), 'EX', 60);
res.json(stats);
});
Session Storage¶
Storing sessions in Redis (instead of in-process memory) is essential once you run more than one server instance — an in-memory session on Server A is invisible to Server B, so a user whose next request lands on a different server would appear logged out.
import session from 'express-session';
import RedisStore from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { maxAge: 3600000 }, // 1 hour, in milliseconds
}));
Rate Limiting¶
A sliding or fixed-window rate limiter needs a fast, shared counter — exactly what Redis
strings (with INCR) or sorted sets are built for:
async function isRateLimited(userId, limit = 100, windowSeconds = 60) {
const key = `rate:${userId}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds); // start the window on the first hit
}
return count > limit;
}
Leaderboards¶
Sorted sets are a natural fit for leaderboards: Redis keeps members ordered by score automatically, so ranking queries are fast without you re-sorting anything yourself.
await redis.zadd('game:leaderboard', 4820, 'user:17');
const top10 = await redis.zrevrange('game:leaderboard', 0, 9, 'WITHSCORES');
const myRank = await redis.zrevrank('game:leaderboard', 'user:17'); // 0-indexed
Try It Yourself¶
- Implement a cache-aside function for a
GET /api/users/:idendpoint against any database you've used before (or a mock async function standing in for one). Add a TTL, then deliberately update the underlying record and observe how long the API keeps returning stale data until the TTL expires. - Using
redis-cli(or a script), build a small sorted-set leaderboard: add at least five members with scores, retrieve the top 3, and retrieve one specific member's rank.
Key Takeaways¶
- Redis is an in-memory data store with rich data structures — strings, hashes, lists, sets, and sorted sets — not just a flat key-value cache.
- Cache-aside is the most common and resilient pattern; write-through keeps the cache always consistent at the cost of write latency; write-behind is fastest but risks data loss.
- Every cached value should have a TTL, and your eviction policy determines what
happens when Redis runs out of memory —
allkeys-lruis a common default for pure caches. - A cache stampede can take down your database when a popular key expires under load; locking and jittered early expiration both mitigate it.
- Beyond simple caching, Redis is the standard tool for shared session storage across
multiple server instances, rate limiting (via
INCRor sorted sets), and real-time leaderboards.