Episode 6 — Scaling Reliability Microservices Web3 / 6.6 — Caching in Production

6.6.a — Redis Caching

In one sentence: Redis is a blazing-fast, in-memory data store that serves as the industry-standard caching layer in production systems — storing everything from API responses to user sessions with sub-millisecond latency and rich data structures that go far beyond simple key-value storage.

Navigation: ← 6.6 Overview · 6.6.b — Cache Invalidation →


1. What Is Redis?

Redis (Remote Dictionary Server) is an open-source, in-memory data structure store. Unlike traditional databases that write to disk first and read from disk, Redis keeps all data in RAM, making reads and writes extraordinarily fast.

Traditional Database:
  Client → Query → Disk seek (5-20ms) → Read data → Return
  Throughput: ~10,000 operations/second

Redis:
  Client → Command → RAM lookup (0.1-0.5ms) → Return
  Throughput: ~100,000+ operations/second

Redis is not just a cache. It can function as:

RoleDescription
CacheStore frequently accessed data to avoid expensive DB queries
Session storeStore user sessions across multiple app servers
Message brokerPub/sub messaging between services
Rate limiterTrack and enforce API request limits
LeaderboardSorted sets for real-time rankings
QueueJob queues with lists (or Redis Streams)

But in this lesson, we focus on its most common role: caching.


2. Why Redis for Caching

Speed comparison

OperationPostgreSQLMongoDBRedis
Simple key lookup1-5ms1-5ms0.1-0.5ms
Complex query10-500ms10-200msN/A (pre-computed)
Write5-50ms2-20ms0.1-0.5ms
Throughput (ops/sec)~5K-20K~10K-50K~100K-500K

Why not just use an in-process cache (like a JavaScript Map)?

In-process cache (Map/Object):
  ✅ Fastest possible (no network hop)
  ❌ Not shared between server instances
  ❌ Lost when the process restarts
  ❌ Consumes application memory
  ❌ No built-in expiration

Redis:
  ✅ Shared between ALL server instances
  ✅ Survives application restarts (persistence options)
  ✅ Dedicated memory management
  ✅ Built-in TTL (auto-expiration)
  ✅ Rich data structures
  ✅ Pub/sub for invalidation
  ⚠️ Slight network overhead (~0.5ms)

In production, you almost always run multiple instances of your application behind a load balancer. An in-process cache means each instance has its own separate cache — User A might see stale data on Server 1 while Server 2 has the updated version. Redis solves this by being a shared, centralized cache.


3. Installing and Running Redis

Using Docker (recommended for development)

# Pull and run Redis
docker run --name redis-dev -p 6379:6379 -d redis:7-alpine

# Verify it's running
docker exec -it redis-dev redis-cli ping
# Output: PONG

# Connect to the Redis CLI
docker exec -it redis-dev redis-cli

Using Docker Compose (for projects)

# docker-compose.yml
version: '3.8'
services:
  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'
    volumes:
      - redis-data:/data
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

volumes:
  redis-data:

The --maxmemory-policy allkeys-lru flag tells Redis to automatically evict the least recently used keys when memory is full — critical for a cache that should never run out of memory.

On macOS (Homebrew)

brew install redis
brew services start redis
redis-cli ping  # PONG

4. Redis Data Types

Redis is more than key-value. Each data type has specific use cases in caching:

Strings — The most common cache type

# Set a value with TTL (EX = seconds)
SET user:123 '{"name":"Alice","email":"alice@example.com"}' EX 3600

# Get the value
GET user:123

# Set only if key doesn't exist (useful for locks)
SET lock:user:123 "1" NX EX 30

Hashes — Cache objects with field-level access

# Store a user object as a hash
HSET user:123 name "Alice" email "alice@example.com" plan "premium"

# Get a single field (no need to deserialize the entire object)
HGET user:123 plan
# "premium"

# Get all fields
HGETALL user:123

# Set TTL on the entire hash
EXPIRE user:123 3600

Lists — Ordered collections (queues, recent items)

# Cache last 10 notifications for a user
LPUSH notifications:user:123 '{"type":"like","from":"Bob"}'
LTRIM notifications:user:123 0 9   # Keep only latest 10

# Get last 5 notifications
LRANGE notifications:user:123 0 4

Sets — Unique collections (tags, permissions)

# Cache user's roles
SADD roles:user:123 "admin" "editor" "viewer"

# Check if user has a specific role
SISMEMBER roles:user:123 "admin"
# (integer) 1

Sorted Sets — Ranked data (leaderboards, trending)

# Cache product search results ranked by score
ZADD search:laptops 99.5 "product:1001" 95.2 "product:1002" 88.7 "product:1003"

# Get top 10 results
ZREVRANGE search:laptops 0 9 WITHSCORES

5. Connecting from Node.js with ioredis

ioredis is the most popular and feature-rich Redis client for Node.js.

npm install ioredis

Basic connection

import Redis from 'ioredis';

// Simple connection
const redis = new Redis({
  host: '127.0.0.1',
  port: 6379,
  // password: 'your-password',   // if Redis requires auth
  // db: 0,                       // database number (0-15)
  retryStrategy(times) {
    const delay = Math.min(times * 50, 2000);
    return delay; // Reconnect after delay (ms)
  },
});

redis.on('connect', () => console.log('Redis connected'));
redis.on('error', (err) => console.error('Redis error:', err));

Basic operations

// SET — store a value with optional TTL
await redis.set('user:123', JSON.stringify({ name: 'Alice', plan: 'premium' }));
await redis.set('user:123', JSON.stringify({ name: 'Alice' }), 'EX', 3600); // 1 hour TTL

// GET — retrieve a value
const raw = await redis.get('user:123');
const user = raw ? JSON.parse(raw) : null;

// DEL — delete a key
await redis.del('user:123');

// EXISTS — check if key exists (returns 0 or 1)
const exists = await redis.exists('user:123');

// TTL — check remaining time to live (seconds)
const ttl = await redis.ttl('user:123');
// -1 = no expiry, -2 = key doesn't exist, positive = seconds remaining

// MGET — get multiple keys at once (batch read)
const [user1, user2, user3] = await redis.mget('user:1', 'user:2', 'user:3');

// INCR — atomic counter (great for rate limiting)
await redis.incr('api:requests:user:123');
await redis.expire('api:requests:user:123', 60); // Reset counter every 60s

Hash operations (field-level caching)

// Store object fields individually
await redis.hset('user:123', {
  name: 'Alice',
  email: 'alice@example.com',
  plan: 'premium',
  loginCount: '42',
});

// Get a single field (no need to deserialize the entire object)
const plan = await redis.hget('user:123', 'plan');

// Get all fields
const userData = await redis.hgetall('user:123');
// { name: 'Alice', email: 'alice@example.com', plan: 'premium', loginCount: '42' }

// Increment a single field atomically
await redis.hincrby('user:123', 'loginCount', 1);

6. Caching Patterns

The choice of caching pattern defines when data enters the cache, when it is updated, and who is responsible for keeping the cache in sync with the database.

Pattern 1: Cache-Aside (Lazy Loading)

The most common pattern. The application manages the cache explicitly.

READ:
  1. App checks cache for key
  2. HIT  → return cached data
  3. MISS → query database → store result in cache → return data

WRITE:
  1. App writes to database
  2. App deletes (invalidates) the cache key
  3. Next read triggers a cache MISS → repopulates from DB
async function getUserById(userId) {
  const cacheKey = `user:${userId}`;

  // Step 1: Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log('Cache HIT');
    return JSON.parse(cached);
  }

  // Step 2: Cache MISS — query database
  console.log('Cache MISS');
  const user = await db.collection('users').findOne({ _id: userId });

  if (user) {
    // Step 3: Populate cache with TTL
    await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
  }

  return user;
}

async function updateUser(userId, updates) {
  // Step 1: Update database
  await db.collection('users').updateOne({ _id: userId }, { $set: updates });

  // Step 2: Invalidate cache (delete, don't update)
  await redis.del(`user:${userId}`);
  // Next read will repopulate the cache from the fresh DB data
}

Why delete instead of update the cache? Deleting is safer — it avoids race conditions where two concurrent writes could leave the cache in an inconsistent state. The next read will always fetch the latest data from the database.

Pattern 2: Write-Through

Every write goes to both the cache and the database. Reads always hit the cache.

WRITE:
  1. App writes to cache
  2. Cache layer writes to database (synchronously)
  3. Return success

READ:
  1. App reads from cache (always present if written through)
async function writeThrough(key, data) {
  // Write to database first (source of truth)
  await db.collection('products').updateOne(
    { _id: data.id },
    { $set: data },
    { upsert: true }
  );

  // Then update cache
  await redis.set(key, JSON.stringify(data), 'EX', 7200);
}

async function readThrough(key, fetchFn) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const data = await fetchFn();
  if (data) {
    await redis.set(key, JSON.stringify(data), 'EX', 7200);
  }
  return data;
}

Tradeoff: Writes are slower (double write), but reads are always fast and cache is always warm.

Pattern 3: Write-Behind (Write-Back)

Writes go to the cache first, then asynchronously to the database. The cache is the primary write target.

WRITE:
  1. App writes to cache (immediate return)
  2. Background process flushes cache to database (async)

READ:
  1. App reads from cache (always present)
async function writeBehind(key, data) {
  // Write to cache immediately (fast response)
  await redis.set(key, JSON.stringify(data), 'EX', 7200);

  // Queue a background write to the database
  await redis.lpush('write-queue', JSON.stringify({
    key,
    data,
    timestamp: Date.now(),
  }));
}

// Background worker that flushes writes to DB
async function processWriteQueue() {
  while (true) {
    // Block until an item is available (BRPOP blocks for 5 seconds)
    const item = await redis.brpop('write-queue', 5);
    if (item) {
      const { key, data } = JSON.parse(item[1]);
      await db.collection('products').updateOne(
        { _id: data.id },
        { $set: data },
        { upsert: true }
      );
    }
  }
}

Tradeoff: Fastest writes, but risk of data loss if Redis crashes before flushing to the database. Use only for data you can afford to lose temporarily (analytics, counters, non-critical updates).

Pattern 4: Read-Through

The cache itself is responsible for loading data on a miss. The application only talks to the cache, never directly to the database.

// A read-through cache wrapper
class ReadThroughCache {
  constructor(redis, fetchFn, defaultTTL = 3600) {
    this.redis = redis;
    this.fetchFn = fetchFn;
    this.defaultTTL = defaultTTL;
  }

  async get(key) {
    const cached = await this.redis.get(key);
    if (cached) return JSON.parse(cached);

    // Cache is responsible for fetching from the source
    const data = await this.fetchFn(key);
    if (data) {
      await this.redis.set(key, JSON.stringify(data), 'EX', this.defaultTTL);
    }
    return data;
  }
}

// Usage — application never touches the DB directly for reads
const userCache = new ReadThroughCache(redis, async (key) => {
  const userId = key.replace('user:', '');
  return db.collection('users').findOne({ _id: userId });
});

const user = await userCache.get('user:123');

Pattern comparison

PatternRead SpeedWrite SpeedData FreshnessComplexityRisk
Cache-asideFast (on hit)NormalEventually consistentLowStale reads on miss window
Write-throughAlways fastSlower (double write)Always freshMediumWrite latency
Write-behindAlways fastFastestEventually consistentHighData loss risk
Read-throughFast (on hit)NormalEventually consistentMediumCold start misses

7. Cache Warming

Cache warming means pre-loading the cache with data before traffic arrives, so users never experience a cold-cache miss.

async function warmCache() {
  console.log('Warming cache...');

  // Pre-load popular products
  const topProducts = await db.collection('products')
    .find({ featured: true })
    .limit(100)
    .toArray();

  const pipeline = redis.pipeline();
  for (const product of topProducts) {
    pipeline.set(
      `product:${product._id}`,
      JSON.stringify(product),
      'EX',
      7200
    );
  }

  await pipeline.exec(); // Execute all commands in a single round-trip
  console.log(`Warmed ${topProducts.length} products`);
}

// Call on application startup
warmCache().catch(console.error);

When to warm: After a deploy (cache was cleared), after a Redis restart, before a known traffic spike (product launch, marketing campaign).


8. Redis for Sessions

Storing sessions in Redis instead of in-memory or a database is a production best practice:

npm install express-session connect-redis
import session from 'express-session';
import RedisStore from 'connect-redis';

const redisStore = new RedisStore({ client: redis });

app.use(session({
  store: redisStore,
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
  },
}));

// Sessions now work across all server instances
app.get('/dashboard', (req, res) => {
  if (!req.session.userId) {
    return res.redirect('/login');
  }
  res.json({ user: req.session.userId });
});

Why Redis for sessions?

  • Sessions are shared across all app instances behind a load balancer
  • Session data survives app restarts
  • Redis auto-expires old sessions via TTL
  • Sub-millisecond session lookups

9. Redis in Microservices (Shared Cache)

In a microservices architecture, Redis serves as the shared caching layer that all services can read from and write to:

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  User Service │  │ Order Service │  │Product Service│
└──────┬───────┘  └──────┬───────┘  └──────┬───────┘
       │                 │                  │
       └────────────┬────┴──────────────────┘
                    │
              ┌─────▼─────┐
              │   Redis    │  Shared cache + pub/sub
              │   Cluster  │  for cross-service invalidation
              └────────────┘
// User Service — caches user data
await redis.set('user:123', JSON.stringify(user), 'EX', 3600);

// Order Service — reads cached user data (avoids calling User Service API)
const user = JSON.parse(await redis.get('user:123'));

// Product Service — publishes invalidation events
await redis.publish('cache:invalidate', JSON.stringify({
  pattern: 'product:*',
  reason: 'price-update',
}));

// All services subscribe to invalidation events
redis.subscribe('cache:invalidate');
redis.on('message', async (channel, message) => {
  const { pattern } = JSON.parse(message);
  // Use SCAN to find and delete matching keys
  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');
});

10. Complete Express.js + Redis Caching Middleware

Here is a production-ready caching middleware that you can drop into any Express application:

import express from 'express';
import Redis from 'ioredis';

const app = express();
const redis = new Redis({ host: '127.0.0.1', port: 6379 });

// ──────────────────────────────────────────────────────────
// Generic caching middleware factory
// ──────────────────────────────────────────────────────────
function cacheMiddleware(options = {}) {
  const {
    ttl = 300,                          // Default 5 minutes
    keyPrefix = 'cache:',               // Namespace prefix
    keyGenerator = (req) => req.originalUrl, // Default: URL as key
    condition = (req) => req.method === 'GET', // Only cache GETs
  } = options;

  return async (req, res, next) => {
    // Skip caching if condition is not met
    if (!condition(req)) return next();

    const cacheKey = `${keyPrefix}${keyGenerator(req)}`;

    try {
      // Check cache
      const cached = await redis.get(cacheKey);

      if (cached) {
        const { body, statusCode, headers } = JSON.parse(cached);
        res.set('X-Cache', 'HIT');
        res.set('Content-Type', headers['content-type'] || 'application/json');
        return res.status(statusCode).send(body);
      }

      // Cache MISS — intercept the response to capture it
      res.set('X-Cache', 'MISS');
      const originalJson = res.json.bind(res);

      res.json = (data) => {
        // Store the response in cache (fire-and-forget)
        const cachePayload = JSON.stringify({
          body: data,
          statusCode: res.statusCode,
          headers: { 'content-type': 'application/json' },
        });
        redis.set(cacheKey, cachePayload, 'EX', ttl).catch(console.error);

        // Send the original response
        return originalJson(data);
      };

      next();
    } catch (err) {
      // If Redis is down, skip caching — don't break the app
      console.error('Cache middleware error:', err.message);
      next();
    }
  };
}

// ──────────────────────────────────────────────────────────
// Cache invalidation helper
// ──────────────────────────────────────────────────────────
async function invalidateCache(pattern) {
  let cursor = '0';
  let deletedCount = 0;
  do {
    const [nextCursor, keys] = await redis.scan(
      cursor, 'MATCH', pattern, 'COUNT', 100
    );
    cursor = nextCursor;
    if (keys.length > 0) {
      await redis.del(...keys);
      deletedCount += keys.length;
    }
  } while (cursor !== '0');
  return deletedCount;
}

// ──────────────────────────────────────────────────────────
// Routes with caching
// ──────────────────────────────────────────────────────────

// Cache product listings for 10 minutes
app.get('/api/products',
  cacheMiddleware({ ttl: 600, keyPrefix: 'cache:products:' }),
  async (req, res) => {
    const products = await db.collection('products').find().toArray();
    res.json(products);
  }
);

// Cache individual product for 30 minutes
app.get('/api/products/:id',
  cacheMiddleware({
    ttl: 1800,
    keyPrefix: 'cache:product:',
    keyGenerator: (req) => req.params.id,
  }),
  async (req, res) => {
    const product = await db.collection('products').findOne({ _id: req.params.id });
    if (!product) return res.status(404).json({ error: 'Not found' });
    res.json(product);
  }
);

// Update product — invalidate related caches
app.put('/api/products/:id', async (req, res) => {
  await db.collection('products').updateOne(
    { _id: req.params.id },
    { $set: req.body }
  );

  // Invalidate this product's cache AND the listings cache
  await invalidateCache(`cache:product:${req.params.id}`);
  await invalidateCache('cache:products:*');

  res.json({ success: true });
});

// ──────────────────────────────────────────────────────────
// Health check for Redis connection
// ──────────────────────────────────────────────────────────
app.get('/health', async (req, res) => {
  try {
    await redis.ping();
    res.json({ status: 'ok', redis: 'connected' });
  } catch {
    res.status(503).json({ status: 'degraded', redis: 'disconnected' });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

11. Key Takeaways

  1. Redis is an in-memory data store — sub-millisecond reads, 100K+ ops/second, rich data structures beyond simple strings.
  2. Cache-aside is the default pattern — check cache first, fetch from DB on miss, store in cache. Simple, safe, effective.
  3. Write-through gives freshness, write-behind gives speed — choose based on your consistency requirements.
  4. Always set a TTL — every cached value should have an expiration. Without TTL, stale data lives forever.
  5. Redis shines in multi-instance deployments — shared sessions, shared cache, pub/sub invalidation across services.
  6. Graceful degradation — if Redis goes down, your app should still work (just slower). Never make cache failures break your application.

Explain-It Challenge

  1. Your team runs 4 instances of an Express app behind a load balancer. A junior developer suggests using a JavaScript Map for caching. Explain why this breaks in production and how Redis solves it.
  2. Describe a scenario where write-behind caching could cause data loss, and explain when the tradeoff is acceptable.
  3. A product page takes 200ms to render from the database. After adding Redis caching, the first request still takes 200ms but subsequent requests take 1ms. Explain why, and how cache warming could help.

Navigation: ← 6.6 Overview · 6.6.b — Cache Invalidation →