TL;DR: Redis caching sits between your application and database, storing frequently accessed data in memory for sub-millisecond reads. This guide walks through cache-aside, write-through, and write-behind patterns with diagrams, working Node.js code, and real-world lessons from building a stock trading platform handling 100K+ concurrent users.
The Problem
Your API is slow. Users are waiting 200-500ms for responses that hit the database every single time. Your PostgreSQL instance is sweating under load, and your cloud bill is climbing.
I hit this exact wall at Mstock — a stock trading platform with 100K+ concurrent users. Every user's dashboard needed real-time portfolio data, watchlist prices, and market depth. Hitting the database for every request? That's a recipe for a crashed production server during market hours.
The answer was Redis. Not as a silver bullet, but as a carefully designed caching layer. Here's how it actually works.
What Is Redis Caching?
Redis is an in-memory data store. "In-memory" is the key phrase — while your database reads from disk (even SSDs have latency), Redis reads from RAM.
┌─────────────────────────────────────────────────────────┐
│ Response Times │
├──────────────────────┬──────────────────────────────────┤
│ RAM (Redis) │ ~0.1 ms │
│ SSD (PostgreSQL) │ ~1-10 ms │
│ Network DB call │ ~10-100 ms │
│ Spinning Disk │ ~10-20 ms │
└──────────────────────┴──────────────────────────────────┘
That's a 10x-1000x difference. When you're serving thousands of requests per second, this matters.
How Caching Fits in Your Architecture
Here's the big picture:
┌──────────────┐
│ Client │
│ (Browser) │
└──────┬───────┘
│
▼
┌──────────────┐
│ API Server │
│ (Node.js) │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
▼ │ │
┌──────────────┐ │ ┌──────────────┐
│ Redis │ │ │ Database │
│ (Cache) │ │ │ (PostgreSQL) │
│ │ │ │ │
│ ⚡ ~0.1ms │ │ │ 🐢 ~10ms │
└──────────────┘ │ └──────────────┘
▲ │ ▲
│ │ │
└────────────┴────────────┘
Read / Write
The API server checks Redis first. If the data is there (a cache hit), it returns instantly. If not (a cache miss), it queries the database, stores the result in Redis, and then returns.
Cache Hit vs Cache Miss — The Core Flow
This is the fundamental pattern. Every caching strategy is a variation of this:
flowchart TD
A[Client Request] --> B{Check Redis Cache}
B -->|Cache HIT| C[Return Cached Data]
B -->|Cache MISS| D[Query Database]
D --> E[Store Result in Redis]
E --> F[Set TTL / Expiry]
F --> G[Return Fresh Data]
style C fill:#22c55e,color:#fff
style D fill:#ef4444,color:#fff
style E fill:#3b82f6,color:#fff
Cache HIT Flow (fast path): Cache MISS Flow (slow path):
Client ──▶ Redis ──▶ Client Client ──▶ Redis ──▶ DB
✅ Found! ⚡ 0.5ms ❌ Not found │
▼
Client ◀── Redis ◀── DB
📝 Store 🐢 15ms
Working Code: Basic Cache-Aside in Node.js
// cache-aside.js — The most common caching pattern
const Redis = require('ioredis');
const { Pool } = require('pg');
const redis = new Redis({
host: '127.0.0.1',
port: 6379,
maxRetriesPerRequest: 3,
});
const db = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function getUserPortfolio(userId) {
const cacheKey = `portfolio:${userId}`;
// Step 1: Check Redis first
const cached = await redis.get(cacheKey);
if (cached) {
console.log('Cache HIT for', cacheKey);
return JSON.parse(cached);
}
// Step 2: Cache MISS — query the database
console.log('Cache MISS for', cacheKey);
const result = await db.query(
'SELECT * FROM portfolios WHERE user_id = $1',
[userId]
);
const portfolio = result.rows[0];
// Step 3: Store in Redis with 60-second TTL
await redis.setex(cacheKey, 60, JSON.stringify(portfolio));
return portfolio;
}
This is called the Cache-Aside pattern (also called "Lazy Loading"). The application manages the cache explicitly — it decides when to read and write to the cache.
The Three Caching Strategies You Need to Know
1. Cache-Aside (Lazy Loading)
The pattern shown above. The application is responsible for all cache operations.
CACHE-ASIDE PATTERN
═══════════════════
READ:
┌─────┐ ①check ┌───────┐
│ App │──────────────▶│ Redis │
│ │◀──────────────│ │
└──┬──┘ hit/miss └───────┘
│
│ ②query (on miss only)
▼
┌──────┐
│ DB │
└──┬───┘
│
│ ③store result in cache
▼
┌───────┐
│ Redis │
└───────┘
WRITE:
┌─────┐ ①write ┌──────┐
│ App │──────────────▶│ DB │
│ │ └──────┘
│ │ ②invalidate ┌───────┐
│ │──────────────▶│ Redis │ DEL key
└─────┘ └───────┘
When to use: Read-heavy workloads. Most web applications. This is your default choice.
Pros: Only caches data that's actually requested. Cache failures don't break the app.
Cons: First request is always slow (cache miss). Stale data possible between write and TTL expiry.
2. Write-Through
Every write goes to the cache AND the database simultaneously.
WRITE-THROUGH PATTERN
═════════════════════
WRITE:
┌─────┐ ┌───────┐ ┌──────┐
│ App │──── ①write ──▶│ Redis │──── ②write ──▶│ DB │
└─────┘ └───────┘ └──────┘
READ:
┌─────┐ ┌───────┐
│ App │──── ①read ───▶│ Redis │ (always fresh!)
└─────┘ └───────┘
sequenceDiagram
participant App
participant Redis
participant DB
App->>Redis: SET user:123 data
Redis->>DB: INSERT/UPDATE user 123
DB-->>Redis: OK
Redis-->>App: OK
Note over App,DB: Later, on read...
App->>Redis: GET user:123
Redis-->>App: data (always fresh)
When to use: When data consistency is critical and you can tolerate slightly slower writes.
// write-through.js
async function updatePortfolio(userId, data) {
const cacheKey = `portfolio:${userId}`;
// Write to both cache and DB
const pipeline = redis.pipeline();
pipeline.setex(cacheKey, 300, JSON.stringify(data));
await Promise.all([
pipeline.exec(),
db.query(
'UPDATE portfolios SET data = $1 WHERE user_id = $2',
[JSON.stringify(data), userId]
),
]);
}
Pros: Cache is always consistent with DB. Reads are always fast.
Cons: Write latency increases (writing to two places). Cache may fill with data that's never read.
3. Write-Behind (Write-Back)
Writes go to the cache immediately, and the database is updated asynchronously in the background.
WRITE-BEHIND PATTERN
════════════════════
WRITE:
┌─────┐ ①write ┌───────┐ ┌──────┐
│ App │─────────────▶│ Redis │ ──③──── │ DB │
└─────┘ (instant) └───────┘ async └──────┘
│ batch
│ write
②queue
┌───────┐
│ Queue │
└───────┘
Timeline:
──────────────────────────────────────────────▶ time
│ App writes │ │ DB updated │
│ to Redis │ │ (later) │
│ ⚡ instant │ │ 🐢 async │
// write-behind.js — Using Bull queue for async DB writes
const Queue = require('bull');
const writeQueue = new Queue('db-writes', 'redis://127.0.0.1:6379');
async function updateWatchlist(userId, stocks) {
const cacheKey = `watchlist:${userId}`;
// Step 1: Write to Redis immediately (user sees instant response)
await redis.setex(cacheKey, 600, JSON.stringify(stocks));
// Step 2: Queue the database write (happens in background)
await writeQueue.add('update-watchlist', {
userId,
stocks,
timestamp: Date.now(),
});
}
// Background worker processes the queue
writeQueue.process('update-watchlist', async (job) => {
const { userId, stocks } = job.data;
await db.query(
'UPDATE watchlists SET stocks = $1 WHERE user_id = $2',
[JSON.stringify(stocks), userId]
);
});
When to use: Write-heavy workloads where you can tolerate eventual consistency. Gaming leaderboards, analytics counters, activity feeds.
Pros: Fastest writes (only hitting RAM). Can batch DB writes for efficiency.
Cons: Risk of data loss if Redis crashes before DB write. More complex architecture.
Strategy Comparison
┌─────────────────┬───────────────┬────────────────┬───────────────┐
│ │ Cache-Aside │ Write-Through │ Write-Behind │
├─────────────────┼───────────────┼────────────────┼───────────────┤
│ Read Speed │ ⚡ Fast* │ ⚡ Fast │ ⚡ Fast │
│ Write Speed │ Normal │ 🐢 Slower │ ⚡ Fastest │
│ Consistency │ Eventual │ ✅ Strong │ ❌ Eventual │
│ Data Loss Risk │ None │ None │ ⚠️ Possible │
│ Complexity │ Low │ Medium │ High │
│ Best For │ Read-heavy │ Read+Write │ Write-heavy │
├─────────────────┼───────────────┼────────────────┼───────────────┤
│ * After first │ │ │ │
│ cache miss │ │ │ │
└─────────────────┴───────────────┴────────────────┴───────────────┘
TTL Strategies — When Should Cache Expire?
TTL (Time-To-Live) is how long data stays in the cache before Redis automatically deletes it. Getting this right is critical.
flowchart LR
A[Data Written to Cache] --> B[TTL Timer Starts]
B --> C{TTL Expired?}
C -->|No| D[Serve from Cache]
C -->|Yes| E[Key Deleted]
E --> F[Next Request = Cache Miss]
F --> G[Fetch from DB & Re-cache]
style D fill:#22c55e,color:#fff
style E fill:#ef4444,color:#fff
TTL Decision Guide
How to Choose TTL
═════════════════
Changes every second? (stock prices, live scores)
└──▶ TTL: 1-5 seconds OR don't cache at all
Changes every few minutes? (user sessions, dashboards)
└──▶ TTL: 30-300 seconds
Changes rarely? (user profiles, product catalog)
└──▶ TTL: 1-24 hours
Never changes? (country lists, configs)
└──▶ TTL: 24+ hours OR no TTL (manual invalidation)
// ttl-strategies.js — Different TTL for different data types
const TTL = {
STOCK_PRICE: 2, // 2 seconds — changes constantly
MARKET_DEPTH: 5, // 5 seconds — near real-time
USER_SESSION: 1800, // 30 minutes
PORTFOLIO: 60, // 1 minute — balance between freshness and speed
USER_PROFILE: 3600, // 1 hour — rarely changes
STATIC_CONFIG: 86400, // 24 hours
};
async function cacheWithTTL(key, fetchFn, ttl) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await fetchFn();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
// Usage
const portfolio = await cacheWithTTL(
`portfolio:${userId}`,
() => db.query('SELECT * FROM portfolios WHERE user_id = $1', [userId]).then(r => r.rows[0]),
TTL.PORTFOLIO
);
Cache Invalidation — The Hard Part
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
When the underlying data changes, you need to remove or update the cached version. Here are the strategies:
Strategy 1: Delete on Write (Most Common)
// invalidation.js — Delete cache when data changes
async function updateUserProfile(userId, newData) {
// Update the database
await db.query(
'UPDATE users SET name = $1, email = $2 WHERE id = $3',
[newData.name, newData.email, userId]
);
// Delete the cached version — next read will re-populate
await redis.del(`user:${userId}`);
}
Strategy 2: Pattern-Based Invalidation
When one change affects multiple cache keys:
// When a user changes their profile, invalidate all related caches
async function invalidateUserCaches(userId) {
// Find all keys matching the pattern
const keys = await redis.keys(`user:${userId}:*`);
if (keys.length > 0) {
await redis.del(...keys);
}
}
Warning:
redis.keys()scans the entire keyspace and blocks Redis. UseSCANin production:
// Production-safe pattern invalidation using SCAN
async function safeInvalidate(pattern) {
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(
cursor, 'MATCH', pattern, 'COUNT', 100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}
Strategy 3: Pub/Sub Invalidation (Multi-Server)
When you have multiple app servers, each with their own connection to Redis:
PUB/SUB INVALIDATION
════════════════════
┌──────────┐ publish ┌──────────┐
│ Server 1 │───────────▶│ Redis │
│ (writer) │ │ Channel: │
└──────────┘ │ "cache- │
│ invalid" │
└────┬─────┘
subscribe │
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Server 1 │ │ Server 2 │ │ Server 3 │
│ DEL key │ │ DEL key │ │ DEL key │
└──────────┘ └──────────┘ └──────────┘
Real-World Example: Caching at Mstock (Stock Trading Platform)
At Mstock, we had 100K+ concurrent users during market hours (9:15 AM - 3:30 PM IST). Here's how we structured our caching:
The Architecture
MSTOCK CACHING ARCHITECTURE
════════════════════════════
┌──────────────────────────────────────────────────┐
│ Client Layer │
│ 100K+ WebSocket connections │
└──────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ API Gateway (Nginx) │
│ Rate limiting, load balancing │
└──────────────────────┬───────────────────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Node.js │ │ Node.js │ │ Node.js │
│ Instance 1 │ │ Instance 2 │ │ Instance 3 │
└──────┬─────┘ └──────┬─────┘ └──────┬─────┘
│ │ │
└──────────────┼──────────────┘
│
┌────────────┼────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Redis Cluster │ │ PostgreSQL │
│ │ │ (Primary) │
│ ┌────┐ ┌────┐ │ │ │
│ │ M1 │ │ M2 │ │ │ Portfolios │
│ └────┘ └────┘ │ │ Orders │
│ ┌────┐ ┌────┐ │ │ Users │
│ │ M3 │ │ M4 │ │ │ │
│ └────┘ └────┘ │ └─────────────────┘
│ │
│ Hot data: │
│ - Stock prices │
│ - Watchlists │
│ - User sessions │
│ - Market depth │
└─────────────────┘
What We Cached (and What We Didn't)
// mstock-cache-config.js — Real caching strategy
const CacheConfig = {
// CACHED: High-read, tolerates slight staleness
stockPrice: {
pattern: 'stock:{symbol}:price',
ttl: 2, // 2 seconds — exchange pushes updates every ~1s
strategy: 'write-through',
example: 'stock:RELIANCE:price',
},
watchlist: {
pattern: 'watchlist:{userId}',
ttl: 300, // 5 minutes
strategy: 'cache-aside',
invalidateOn: 'watchlist-update',
},
userSession: {
pattern: 'session:{sessionId}',
ttl: 1800, // 30 minutes
strategy: 'write-through',
},
marketDepth: {
pattern: 'depth:{symbol}',
ttl: 1, // 1 second — must be near real-time
strategy: 'write-through',
},
// NOT CACHED: Needs to be 100% accurate, no tolerance for stale data
// - Order execution (goes directly to exchange)
// - Account balance (must be real-time for margin checks)
// - Trade history (read from DB, low frequency)
};
The Results
BEFORE REDIS AFTER REDIS
════════════ ═══════════
Avg response time: 180ms Avg response time: 12ms
DB queries/sec: 15,000 DB queries/sec: 2,000
P99 latency: 800ms P99 latency: 45ms
DB CPU usage: 85% DB CPU usage: 25%
┌─────────────────────────────────────────────────┐
│ Response Time (ms) │
│ │
│ 200 ┤ ████ │
│ 180 ┤ ████ │
│ 160 ┤ ████ │
│ 140 ┤ ████ │
│ 120 ┤ ████ │
│ 100 ┤ ████ │
│ 80 ┤ ████ │
│ 60 ┤ ████ │
│ 40 ┤ ████ │
│ 20 ┤ ████ ░░░░ │
│ 0 ┤ ████ ░░░░ │
│ └────────── │
│ Before After │
│ ████ ░░░░ │
└─────────────────────────────────────────────────┘
Cache hit rate: 94% during market hours
That's 94 out of 100 requests served from memory.
Common Mistakes
1. Caching Everything
Not everything needs caching. If data is rarely read or changes constantly, caching adds complexity without benefit.
2. No TTL (Keys Live Forever)
// BAD — key lives forever, stale data accumulates
await redis.set('user:123', JSON.stringify(data));
// GOOD — always set a TTL
await redis.setex('user:123', 3600, JSON.stringify(data));
3. Cache Stampede
When a popular key expires, hundreds of requests simultaneously hit the database.
CACHE STAMPEDE
═════════════
Popular key expires at T=0
T=0.001s: Request 1 → Cache MISS → Query DB
T=0.002s: Request 2 → Cache MISS → Query DB
T=0.003s: Request 3 → Cache MISS → Query DB
...
T=0.050s: Request 200 → Cache MISS → Query DB
200 identical queries hit the database simultaneously!
──▶ DB CPU spikes ──▶ Cascading failures
Fix: Mutex lock (only one request rebuilds the cache)
// cache-stampede-fix.js
async function getWithMutex(key, fetchFn, ttl) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
// Try to acquire lock (NX = only if not exists, EX = 5 second timeout)
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (acquired) {
// This request rebuilds the cache
const data = await fetchFn();
await redis.setex(key, ttl, JSON.stringify(data));
await redis.del(lockKey);
return data;
} else {
// Another request is rebuilding — wait and retry
await new Promise((resolve) => setTimeout(resolve, 100));
return getWithMutex(key, fetchFn, ttl);
}
}
4. Not Handling Redis Failures Gracefully
Redis going down shouldn't crash your app. Fall back to the database:
async function resilientGet(key, fetchFn, ttl) {
try {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
} catch (err) {
console.error('Redis error, falling back to DB:', err.message);
// Don't throw — fall through to database
}
const data = await fetchFn();
try {
await redis.setex(key, ttl, JSON.stringify(data));
} catch (err) {
console.error('Redis write failed:', err.message);
// Data is still returned from DB — cache will recover
}
return data;
}
5. Serialization Overhead
Storing large JSON blobs in Redis negates the speed benefit.
// BAD — caching the entire user object with all relations
await redis.set('user:123', JSON.stringify({
...user,
orders: [...thousandsOfOrders],
activityLog: [...yearsOfActivity],
}));
// GOOD — cache only what's needed for the hot path
await redis.set('user:123:summary', JSON.stringify({
id: user.id,
name: user.name,
portfolioValue: user.portfolioValue,
}));
When to Use Redis Caching / When Not to Use It
USE REDIS CACHING WHEN: DON'T USE WHEN:
════════════════════════ ════════════════
✅ Read-heavy workloads (>80% reads) ❌ Write-heavy with strong
✅ Data can tolerate slight staleness consistency requirements
✅ Same data requested repeatedly ❌ Data is unique per request
✅ Database is a bottleneck ❌ Data changes every second
✅ Response time is critical ❌ Payload is very large (>1MB)
✅ You can handle cache invalidation ❌ You can't afford the
operational overhead
Key Takeaways
- Cache-Aside is your default strategy — simple, effective, handles failures gracefully
- Write-Through when you need consistency — slower writes but cache is always fresh
- Write-Behind for write-heavy workloads — fastest writes but risk of data loss
- Always set a TTL — never let keys live forever
- Guard against cache stampedes — use mutex locks for popular keys
- Don't cache everything — only cache data that's read frequently and can tolerate staleness
- Handle Redis failures — your app should work (slower) without the cache
- Monitor your hit rate — if it's below 80%, rethink your strategy
Connect with Me
If you found this useful, I write deep-dive visual guides on system design, Node.js, and AI engineering — 2 posts every week (Monday + Thursday).
- Twitter/X: @robinsingh — daily tips, threads, and quick takes
- LinkedIn: Robin Singh — long-form posts and career updates
- GitHub: robins163 — source code from all my articles
- Hashnode: unknowntoplay.hashnode.dev — canonical home for all my posts
- Substack: Newsletter — subscribe for weekly system design breakdowns delivered to your inbox
This post is part of the **System Design Visual Guides* series. Next up: WebSocket vs SSE vs Long Polling — the complete visual comparison.*
Top comments (0)