Introduction

Caching is not optimization. It's a trade-off -- you're trading correctness for speed, and you need to know exactly how much correctness you're giving up.

Every cached value is a lie you're telling your application. A useful lie, usually. But a lie that can become dangerous the moment the underlying data changes and the cache doesn't know about it. The question isn't whether to cache. It's what to cache, for how long, and what happens when the cache is wrong.

Before writing a single Redis command, you need a decision framework. What data changes rarely enough to tolerate staleness? What data would cause real bugs if served stale? And how do you choose a TTL that balances freshness against database load? Those questions come first. The Redis commands are the easy part.

Redis Data Structures for Caching

Redis ships with purpose-built data structures. Most people treat it as a key-value store and shove JSON into string keys for everything. That works until you need to read one field from a large cached object and you're deserializing the whole thing on every hit. Wrong data structure, wrong access pattern, wasted CPU.

Strings

The default. JSON blobs, serialized objects, counters. SET, GET, attach a TTL inline.

node-strings.js
import { createClient } from'redis';
const client = createClient({ url: 'redis://localhost:6379' });
await client.connect();
// Cache a JSON response with a 5-minute TTLconst userData = { id: 42, name: 'Jane Doe', role: 'admin' };
await client.set(
 'user:42',
 JSON.stringify(userData),
 { EX: 300 } // expires in 300 seconds
);
// Retrieve the cached valueconst cached = await client.get('user:42');
if (cached) {
 const user = JSON.parse(cached);
 console.log(`Cache hit: ${user.name}`);
} else {
 console.log('Cache miss - fetching from database');
}

Hashes

Field-value pairs under one key. User profiles, product records, anything where you need partial reads. HSET writes a field, HGET reads one. No deserialization round-trip. And no, you shouldn't store every object as a hash -- if you always read the whole thing, a JSON string is simpler.

Lists

Ordered sequences. Push to the head or tail, pop from either end. Activity feeds, job queues. Constant-time access at both ends.

Sets and Sorted Sets

Sets: unordered unique strings. Sorted sets: same thing but ranked by a numeric score. Leaderboards, priority queues, time-series indexes. The sorted set is the most underrated structure in Redis. Look up the ZADD and ZRANGE syntax when you need it -- the important thing is knowing it exists.

hash-and-sorted-set.py
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Store a product as a hash
r.hset('product:1001', mapping={
 'name': 'Wireless Keyboard',
 'price': '79.99',
 'stock': '245',
 'category': 'electronics'
})
r.expire('product:1001', 600) # 10-minute TTL# Read a single field without fetching everything
price = r.hget('product:1001', 'price')
print(f'Price: ${price}')
# Track trending products with a sorted set
r.zadd('trending:products', {
 'product:1001': 150, # 150 views'product:1002': 340, # 340 views'product:1003': 89, # 89 views
})
# Get top 5 trending products
top_products = r.zrevrange('trending:products', 0, 4, withscores=True)
for product, score in top_products:
 print(f'{product}: {int(score)} views')

Match the structure to the access pattern. Not to what feels clever.

Cache-Aside Pattern

The application checks the cache first. Hit? Return it. Miss? Query the database, write the result into the cache, then return it. The cache never talks to the database directly -- your application code does all the coordination. This is cache-aside, sometimes called lazy loading. Most production caching works this way because it only caches data that someone actually requests. No memory wasted on entries nobody reads. The tradeoff is that every first request pays the full database round trip, but popular keys get warm fast and stay warm. Here is what the flow looks like in code:

cache-aside.js
import { createClient } from'redis';
import db from'./database.js';
const redis = createClient({ url: 'redis://localhost:6379' });
await redis.connect();
async functiongetUser(userId) {
 const cacheKey = `user:${userId}`;
 // Step 1: Check the cacheconst cached = await redis.get(cacheKey);
 if (cached) {
 console.log('Cache hit');
 return JSON.parse(cached);
 }
 // Step 2: Cache miss - query the database
 console.log('Cache miss - querying database');
 const user = await db.query(
 'SELECT * FROM users WHERE id = $1',
 [userId]
 );
 if (!user) return null;
 // Step 3: Populate the cache for future requestsawait redis.set(cacheKey, JSON.stringify(user), { EX: 600 });
 return user;
}
// Usage in an Express route
app.get('/api/users/:id', async (req, res) => {
 const user = awaitgetUser(req.params.id);
 if (!user) return res.status(404).json({ error: 'User not found' });
 res.json(user);
});

The danger here is the thundering herd. Popular key expires. Dozens of requests arrive simultaneously. All miss. All slam the database at once.

Fix: a lock. The first request acquires a short-lived Redis lock with SET ... NX EX, fetches from the database, repopulates the cache. Everyone else waits briefly and retries. But you probably won't need this until you're handling real traffic spikes -- and when you do need it, you'll know because your database monitoring will scream.

Write-Through and Write-Behind Patterns

Write-Through

Every write updates both the cache and the database in one operation. Always consistent. Slower -- two writes per mutation. For inventory counts or financial balances, the latency cost is worth the consistency guarantee.

Write-Behind (Write-Back)

Flip it. Write to the cache, return immediately, flush to the database later in batches. Fast. But if Redis dies before the flush? Data is gone. RDB snapshots and AOF logging shrink the window, but they don't close it. Be honest about whether your application can lose a few seconds of writes.

write-through.js
async functionupdateUserProfile(userId, updates) {
 const cacheKey = `user:${userId}`;
 // Write-through: update DB and cache togetherconst updatedUser = await db.query(
 `UPDATE users SET name = $1, email = $2
 WHERE id = $3 RETURNING *`,
 [updates.name, updates.email, userId]
 );
 // Immediately update the cache with fresh dataawait redis.set(
 cacheKey,
 JSON.stringify(updatedUser),
 { EX: 600 }
 );
 return updatedUser;
}
// Write-behind: queue writes for async processingasync functiontrackPageView(pageId, userId) {
 const event = JSON.stringify({
 pageId, userId, timestamp: Date.now()
 });
 // Write to Redis immediately (fast path)await redis.lPush('analytics:queue', event);
 await redis.incr(`pageviews:${pageId}`);
}
// Background worker flushes to the database in batchesasync functionflushAnalytics() {
 const batchSize = 100;
 const events = [];
 for (let i = 0; i < batchSize; i++) {
 const event = await redis.rPop('analytics:queue');
 if (!event) break;
 events.push(JSON.parse(event));
 }
 if (events.length >0) {
 await db.batchInsert('page_views', events);
 }
}

Most applications mix all three. Cache-aside for bulk reads. Write-through for data where staleness causes real bugs. Write-behind for high-volume fire-and-forget stuff like analytics events. Pick per data type, not per application.

And honestly? Unless you're processing thousands of writes per second, write-through with a tuned database is fast enough. Write-behind adds operational complexity that most teams underestimate.

TTL Strategies and Cache Eviction

Every cached value needs an expiration time. No exceptions. A cached entry with no TTL is a bug waiting to surface weeks later when a customer tries to buy a product that was delisted months ago.

Two questions decide TTL length: how often does this data change, and how bad is it if someone sees a stale version?

  • Site configuration, feature flags -- 5 to 30 minutes. Changes rarely, staleness is invisible.
  • User profiles, preferences -- 5 to 10 minutes. People expect their own changes to show up quickly.
  • Live data (stock prices, scores) -- 5 to 30 seconds, or skip TTL and use pub/sub for real-time invalidation.
  • Aggregated data (dashboard stats, reports) -- 1 to 15 minutes, proportional to computation cost.

Cache Eviction Policies

Redis hits its memory limit. Something has to go.

allkeys-lru -- evicts least recently used keys across everything. Safest default. volatile-lru -- same but only targets keys with a TTL set. allkeys-lfu -- least frequently used, better when popularity varies widely. noeviction -- returns errors when full. Only if you're certain the dataset fits.

Use allkeys-lru. Set maxmemory to roughly 75% of available RAM so background operations have room.

Cache Invalidation Approaches

The database has the truth. The cache has a copy. The copy goes stale. This is where caching gets genuinely hard, and where most caching bugs live.

TTL-Based Expiration

The lazy approach. Set a TTL, let entries vanish on schedule, next request fetches fresh data. Maximum staleness equals your TTL. Good enough when eventual consistency is acceptable -- which is more often than people think.

Explicit Deletion

Application writes to the database, then deletes the corresponding cache key. More precise. The cache clears the moment data changes, not minutes later.

invalidation.py
import redis
import json
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
defupdate_product(product_id, new_data):
 # Update the database first
 db.execute(
 "UPDATE products SET name=%s, price=%s WHERE id=%s",
 (new_data['name'], new_data['price'], product_id)
 )
 # Invalidate the specific cache entry
 r.delete(f'product:{product_id}')
 # Also invalidate any related cache entries
 r.delete(f'category:{new_data["category"]}:products')
 r.delete('homepage:featured_products')
definvalidate_pattern(pattern):
 """Invalidate all keys matching a pattern.
 Use sparingly - SCAN is O(N) but non-blocking."""
 cursor = 0while True:
 cursor, keys = r.scan(cursor, match=pattern, count=100)
 if keys:
 r.delete(*keys)
 if cursor == 0:
 break# Invalidate all cached product datainvalidate_pattern('product:*')

Version-Based Invalidation

Embed a version number in cache keys. Bump it when data changes. Old entries become orphans -- evicted by memory pressure or TTL, whichever comes first. No hunting down individual keys.

Belt and suspenders: TTL on everything as a safety net, explicit deletion on top for data where staleness actually matters to users. If the explicit invalidation fails -- and it will, eventually, because distributed systems -- the TTL prevents stale data from living forever.

Pub/Sub for Real-Time Events

Four application servers, each with a local in-memory cache layered on top of Redis. One server updates the database and deletes a Redis key. The other three still have stale copies in their local caches. Pub/sub fixes that.

Subscribe, publish, handle the message. Standard pattern. It won't replace RabbitMQ or Kafka for durable workloads, but for broadcasting cache invalidation signals across app servers it's hard to beat.

pubsub-invalidation.js
import { createClient } from'redis';
// Subscriber: listens for cache invalidation eventsconst subscriber = createClient({ url: 'redis://localhost:6379' });
await subscriber.connect();
const localCache = new Map();
await subscriber.subscribe('cache:invalidate', (message) => {
 const { key, action } = JSON.parse(message);
 console.log(`Invalidation received: ${action} ${key}`);
 // Clear from local in-memory cacheif (key.includes('*')) {
 // Pattern invalidation: clear matching keysfor (const k of localCache.keys()) {
 if (k.startsWith(key.replace('*', ''))) {
 localCache.delete(k);
 }
 }
 } else {
 localCache.delete(key);
 }
});
// Publisher: broadcasts invalidation after a writeconst publisher = createClient({ url: 'redis://localhost:6379' });
await publisher.connect();
async functioninvalidateAndBroadcast(key) {
 // Delete from Redisawait publisher.del(key);
 // Notify all application serversawait publisher.publish('cache:invalidate', JSON.stringify({
 key,
 action: 'delete',
 source: process.env.SERVER_ID
 }));
}

Caveat: fire-and-forget. Subscriber disconnected when the message goes out? Missed. No queue, no replay. For cache invalidation that's usually fine -- TTLs catch anything that slips through.

If you need guaranteed delivery, use Redis Streams instead. Append-only log. Persistent. Consumer acknowledgments. But most cache invalidation doesn't need that level of reliability.

Session Storage and Rate Limiting

Two of Redis's most common production roles have nothing to do with caching database queries.

Session Storage

Sticky sessions are a scaling headache. Put sessions in Redis instead. Every app server can handle every request because the session data lives in a shared store, not pinned to one server's memory.

sessions-and-rate-limiting.js
import express from'express';
import session from'express-session';
import RedisStore from'connect-redis';
import { createClient } from'redis';
const redisClient = createClient({ url: 'redis://localhost:6379' });
await redisClient.connect();
const app = express();
// Redis-backed session storage
app.use(session({
 store: new RedisStore({ client: redisClient }),
 secret: process.env.SESSION_SECRET,
 resave: false,
 saveUninitialized: false,
 cookie: {
 secure: true,
 httpOnly: true,
 maxAge: 1000 * 60 * 60 * 24// 24 hours
 }
}));
// Sliding-window rate limiter using sorted setsasync functionrateLimiter(req, res, next) {
 const clientIP = req.ip;
 const key = `ratelimit:${clientIP}`;
 const now = Date.now();
 const windowMs = 60000; // 1-minute windowconst maxRequests = 100;
 // Remove entries outside the current windowawait redisClient.zRemRangeByScore(key, 0, now - windowMs);
 // Count requests in the current windowconst requestCount = await redisClient.zCard(key);
 if (requestCount >= maxRequests) {
 return res.status(429).json({
 error: 'Too many requests',
 retryAfter: Math.ceil(windowMs / 1000)
 });
 }
 // Add the current request to the windowawait redisClient.zAdd(key, { score: now, value: `${now}` });
 await redisClient.expire(key, Math.ceil(windowMs / 1000));
 res.set('X-RateLimit-Remaining', maxRequests - requestCount - 1);
 next();
}
app.use('/api', rateLimiter);

Rate Limiting

Why sorted sets instead of a simple counter? Fixed-window counters have a boundary problem. A user fires 100 requests at the end of one window, 100 more at the start of the next. Effectively doubles their throughput in a short burst. The sliding window uses timestamps as scores, pruning expired entries each request. No boundary effects.

If that level of precision doesn't matter, INCR with a TTL gives you a fixed-window counter in two commands.

On sessions: set TTLs. Rotate session IDs on login. Encrypt at rest if compliance requires it.

The number one caching bug isn't stale data -- it's forgetting that the cache exists and debugging the database when the cache is serving old responses. You'll stare at query logs, check indexes, add profiling, and everything looks fine because the database is returning the right data. The cache sitting between the database and the user, quietly serving a response from forty minutes ago, never shows up in your database monitoring. Always check the cache first when something looks wrong.

Nisha Agarwal

Nisha Agarwal

Security Researcher & Database Administrator

Nisha is a security researcher and database administrator with a background in penetration testing. She writes about keeping applications secure and databases performant without overcomplicating things.