DEV Community

Robins163
Robins163

Posted on • Originally published at unknowntoplay.hashnode.dev

How Redis Caching Actually Works — A Visual Guide for Backend Developers

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                       │
└──────────────────────┴──────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
  └─────┘               └───────┘
Enter fullscreen mode Exit fullscreen mode

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!)
  └─────┘              └───────┘
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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]
    ),
  ]);
}
Enter fullscreen mode Exit fullscreen mode

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   │
Enter fullscreen mode Exit fullscreen mode
// 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]
  );
});
Enter fullscreen mode Exit fullscreen mode

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    │               │                │               │
└─────────────────┴───────────────┴────────────────┴───────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
// 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
);
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Warning: redis.keys() scans the entire keyspace and blocks Redis. Use SCAN in 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');
}
Enter fullscreen mode Exit fullscreen mode

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  │
        └──────────┘    └──────────┘    └──────────┘
Enter fullscreen mode Exit fullscreen mode

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  │
  └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
};
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
}));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)