Caching Strategies: When to Use Redis, CDN, or In-Memory

TL;DR

Caching makes apps fast. Use in-memory for single servers, Redis for distributed systems, CDN for static assets. Implement cache-aside for most use cases, write-through for consistency.

My API was taking 800ms to load user dashboards. Every request hit the database with the same complex query. CPU was maxed out. Response times were terrible.

I added a Redis cache and response times dropped to 15ms. Database load dropped 95%. CPU usage went from 90% to 12%. One afternoon of work for a 53x performance improvement.

Caching is the easiest way to make apps fast. Here's how different caching strategies work, when to use each, and complete implementations.

Why Caching Works

Without cache, every request hits the database:

Request → App → Database (800ms)
Request → App → Database (800ms)
Request → App → Database (800ms)
100 requests = 100 database queries

With cache, only the first request hits the database:

Request 1 → App → Database (800ms) → Store in cache
Request 2 → App → Cache (15ms)
Request 3 → App → Cache (15ms)
100 requests = 1 database query + 99 cache hits

Result: 53x faster, 99% less database load.

The Three Cache Patterns

1. Cache-Aside (Lazy Loading)

The most common pattern. Application manages the cache explicitly.

// Cache-aside pattern
async function getUser(userId) {
    const cacheKey = `user:${userId}`;

    // Try cache first
    const cached = await cache.get(cacheKey);
    if (cached) {
        return JSON.parse(cached);
    }

    // Cache miss - load from database
    const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);

    // Store in cache for next time
    await cache.set(cacheKey, JSON.stringify(user), 'EX', 3600); // 1 hour TTL

    return user;
}

// Invalidate cache when data changes
async function updateUser(userId, data) {
    await db.query('UPDATE users SET ? WHERE id = ?', [data, userId]);

    // Remove from cache so next read gets fresh data
    await cache.del(`user:${userId}`);
}

Pros:

  • Simple to implement
  • Only caches data that's actually requested
  • Cache failures don't break the app

Cons:

  • Cache misses cause database hits
  • Stale data possible if cache isn't invalidated
  • Every read checks cache first (slight overhead)

When to use: Most scenarios. Default choice.

2. Write-Through

Write to cache and database simultaneously.

// Write-through pattern
async function updateUser(userId, data) {
    const cacheKey = `user:${userId}`;

    // Write to database
    await db.query('UPDATE users SET ? WHERE id = ?', [data, userId]);

    // Write to cache immediately
    const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
    await cache.set(cacheKey, JSON.stringify(user), 'EX', 3600);

    return user;
}

async function getUser(userId) {
    const cacheKey = `user:${userId}`;

    // Read from cache
    const cached = await cache.get(cacheKey);
    if (cached) {
        return JSON.parse(cached);
    }

    // Cache miss - load and cache
    const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
    await cache.set(cacheKey, JSON.stringify(user), 'EX', 3600);

    return user;
}

Pros:

  • Cache always up to date
  • No stale data
  • Read performance guaranteed

Cons:

  • Write latency increases (two writes)
  • Cache filled with data that might not be read
  • More complex to implement

When to use: When consistency is critical and you can't tolerate stale data.

3. Write-Behind (Write-Back)

Write to cache immediately, sync to database asynchronously.

// Write-behind pattern
const writeQueue = [];

async function updateUser(userId, data) {
    const cacheKey = `user:${userId}`;

    // Write to cache immediately (fast)
    await cache.set(cacheKey, JSON.stringify({ ...data, userId }), 'EX', 3600);

    // Queue database write (async)
    writeQueue.push({ userId, data });

    return { success: true };
}

// Background worker syncs to database
setInterval(async () => {
    while (writeQueue.length > 0) {
        const batch = writeQueue.splice(0, 100); // Process 100 at a time

        for (const { userId, data } of batch) {
            try {
                await db.query('UPDATE users SET ? WHERE id = ?', [data, userId]);
            } catch (error) {
                console.error('Failed to write to database:', error);
                // Implement retry logic
            }
        }
    }
}, 1000);

async function getUser(userId) {
    const cacheKey = `user:${userId}`;

    // Always read from cache
    const cached = await cache.get(cacheKey);
    if (cached) {
        return JSON.parse(cached);
    }

    // Cache miss - load from database
    const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
    await cache.set(cacheKey, JSON.stringify(user), 'EX', 3600);

    return user;
}

Pros:

  • Fastest write performance
  • Reduced database load
  • Can batch writes for efficiency

Cons:

  • Risk of data loss if cache fails before sync
  • Eventual consistency only
  • Complex failure handling

When to use: High-write workloads where eventual consistency is acceptable (analytics, logs).

In-Memory Cache (Single Server)

For apps running on one server, in-memory cache is simplest:

// Simple in-memory cache
class MemoryCache {
    constructor() {
        this.cache = new Map();
        this.timers = new Map();
    }

    set(key, value, ttlSeconds = 3600) {
        // Clear existing timer
        if (this.timers.has(key)) {
            clearTimeout(this.timers.get(key));
        }

        this.cache.set(key, value);

        // Set expiration
        const timer = setTimeout(() => {
            this.cache.delete(key);
            this.timers.delete(key);
        }, ttlSeconds * 1000);

        this.timers.set(key, timer);
    }

    get(key) {
        return this.cache.get(key);
    }

    del(key) {
        if (this.timers.has(key)) {
            clearTimeout(this.timers.get(key));
            this.timers.delete(key);
        }
        this.cache.delete(key);
    }

    clear() {
        for (const timer of this.timers.values()) {
            clearTimeout(timer);
        }
        this.cache.clear();
        this.timers.clear();
    }

    size() {
        return this.cache.size;
    }
}

// Usage
const cache = new MemoryCache();

async function getUser(userId) {
    const cached = cache.get(`user:${userId}`);
    if (cached) return cached;

    const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
    cache.set(`user:${userId}`, user, 3600);

    return user;
}

Pros:

  • No external dependencies
  • Extremely fast (no network)
  • Simple to implement

Cons:

  • Lost on app restart
  • Not shared across servers
  • Limited by available RAM

When to use: Single-server deployments, development, small datasets.

Redis Cache (Distributed)

For multi-server deployments:

const Redis = require('ioredis');
const redis = new Redis({
    host: 'localhost',
    port: 6379,
    retryStrategy: (times) => Math.min(times * 50, 2000)
});

// Cache wrapper with automatic serialization
class RedisCache {
    constructor(redis) {
        this.redis = redis;
    }

    async get(key) {
        const value = await this.redis.get(key);
        return value ? JSON.parse(value) : null;
    }

    async set(key, value, ttlSeconds = 3600) {
        await this.redis.setex(key, ttlSeconds, JSON.stringify(value));
    }

    async del(key) {
        await this.redis.del(key);
    }

    async mget(keys) {
        const values = await this.redis.mget(keys);
        return values.map(v => v ? JSON.parse(v) : null);
    }

    async mset(entries, ttlSeconds = 3600) {
        const pipeline = this.redis.pipeline();

        for (const [key, value] of entries) {
            pipeline.setex(key, ttlSeconds, JSON.stringify(value));
        }

        await pipeline.exec();
    }
}

const cache = new RedisCache(redis);

// Usage
async function getUsers(userIds) {
    const cacheKeys = userIds.map(id => `user:${id}`);

    // Try to get all from cache
    const cached = await cache.mget(cacheKeys);

    // Find missing users
    const missing = [];
    const result = [];

    for (let i = 0; i < userIds.length; i++) {
        if (cached[i]) {
            result[i] = cached[i];
        } else {
            missing.push(userIds[i]);
        }
    }

    // Load missing users from database
    if (missing.length > 0) {
        const users = await db.query(
            'SELECT * FROM users WHERE id IN (?)',
            [missing]
        );

        // Cache missing users
        const entries = users.map(user => [`user:${user.id}`, user]);
        await cache.mset(entries);

        // Add to result
        for (const user of users) {
            const index = userIds.indexOf(user.id);
            result[index] = user;
        }
    }

    return result;
}

Pros:

  • Shared across all servers
  • Persists across app restarts (configurable)
  • Advanced features (pub/sub, sorted sets, etc.)

Cons:

  • Network latency (1-3ms)
  • External dependency to manage
  • More complex than in-memory

When to use: Multi-server deployments, shared state needed.

Cache Key Strategies

Simple Keys

const key = `user:${userId}`;
const key = `post:${postId}`;
const key = `session:${sessionId}`;

Composite Keys

// User's posts
const key = `user:${userId}:posts`;

// Posts by category
const key = `category:${categoryId}:posts:page:${page}`;

// Search results
const key = `search:${encodeURIComponent(query)}:page:${page}`;

Cache Tags for Bulk Invalidation

// Store user data with tags
async function cacheUser(user) {
    await cache.set(`user:${user.id}`, user);

    // Add to user's tag set
    await redis.sadd(`tag:user:${user.id}`, `user:${user.id}`);
    await redis.sadd(`tag:org:${user.orgId}`, `user:${user.id}`);
}

// Invalidate all cache for an org
async function invalidateOrg(orgId) {
    const keys = await redis.smembers(`tag:org:${orgId}`);

    if (keys.length > 0) {
        await redis.del(...keys);
        await redis.del(`tag:org:${orgId}`);
    }
}

Cache Stampede Prevention

When cache expires, many requests hit the database simultaneously:

Cache expires → 100 concurrent requests → 100 database queries

This is called "cache stampede" or "thundering herd"

Solution: Lock and Refresh

async function getWithLock(key, loadFunction, ttl = 3600) {
    // Try cache
    const cached = await cache.get(key);
    if (cached) return cached;

    // Acquire lock
    const lockKey = `lock:${key}`;
    const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);

    if (lockAcquired) {
        // We got the lock - load from database
        try {
            const value = await loadFunction();
            await cache.set(key, value, ttl);
            return value;
        } finally {
            await redis.del(lockKey);
        }
    } else {
        // Someone else is loading - wait and retry
        await new Promise(resolve => setTimeout(resolve, 100));
        return getWithLock(key, loadFunction, ttl);
    }
}

// Usage
const user = await getWithLock(
    `user:${userId}`,
    () => db.query('SELECT * FROM users WHERE id = ?', [userId])
);

Solution: Stale-While-Revalidate

Return stale data while refreshing in background:

async function getWithSWR(key, loadFunction, ttl = 3600, staleTime = 7200) {
    const data = await cache.get(key);

    if (data) {
        const age = Date.now() - data.cachedAt;

        // Fresh data - return immediately
        if (age < ttl * 1000) {
            return data.value;
        }

        // Stale but acceptable - return and refresh in background
        if (age < staleTime * 1000) {
            // Refresh in background
            setImmediate(async () => {
                const fresh = await loadFunction();
                await cache.set(key, {
                    value: fresh,
                    cachedAt: Date.now()
                }, staleTime);
            });

            return data.value;
        }
    }

    // No cache or too stale - load now
    const fresh = await loadFunction();
    await cache.set(key, {
        value: fresh,
        cachedAt: Date.now()
    }, staleTime);

    return fresh;
}

CDN Caching

For static assets and API responses:

// Express middleware for CDN caching
app.use('/api/public', (req, res, next) => {
    // Cache in CDN for 5 minutes
    res.set('Cache-Control', 'public, max-age=300, s-maxage=300');
    next();
});

app.get('/api/public/posts', async (req, res) => {
    const posts = await getPosts();

    // CDN caches this for 5 minutes
    // ETag for conditional requests
    const etag = crypto.createHash('md5').update(JSON.stringify(posts)).digest('hex');
    res.set('ETag', etag);

    if (req.headers['if-none-match'] === etag) {
        return res.status(304).end();
    }

    res.json(posts);
});

// Static assets - cache for 1 year
app.use('/static', express.static('public', {
    maxAge: '1y',
    immutable: true
}));

Cache-Control Headers

// Never cache
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');

// Cache for 5 minutes, validate on each request
res.set('Cache-Control', 'public, max-age=300, must-revalidate');

// Cache for 1 hour, allow stale for 1 day while revalidating
res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');

// Cache forever (for content-addressed assets)
res.set('Cache-Control', 'public, max-age=31536000, immutable');

Multi-Level Caching

Combine multiple cache layers:

class MultiLevelCache {
    constructor(l1Cache, l2Cache) {
        this.l1 = l1Cache; // In-memory (fast)
        this.l2 = l2Cache; // Redis (shared)
    }

    async get(key) {
        // Try L1 (memory) first
        let value = this.l1.get(key);
        if (value) {
            return value;
        }

        // Try L2 (Redis)
        value = await this.l2.get(key);
        if (value) {
            // Populate L1 for next time
            this.l1.set(key, value);
            return value;
        }

        return null;
    }

    async set(key, value, ttl) {
        // Write to both layers
        this.l1.set(key, value, ttl);
        await this.l2.set(key, value, ttl);
    }

    async del(key) {
        // Invalidate both layers
        this.l1.del(key);
        await this.l2.del(key);
    }
}

// Usage
const memCache = new MemoryCache();
const redisCache = new RedisCache(redis);
const cache = new MultiLevelCache(memCache, redisCache);

async function getUser(userId) {
    let user = await cache.get(`user:${userId}`);

    if (!user) {
        user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
        await cache.set(`user:${userId}`, user, 3600);
    }

    return user;
}

Cache Warming

Pre-populate cache before traffic hits:

// Warm cache on startup
async function warmCache() {
    console.log('Warming cache...');

    // Load most popular content
    const popular = await db.query(`
        SELECT id FROM posts
        ORDER BY views DESC
        LIMIT 100
    `);

    for (const post of popular) {
        const data = await db.query('SELECT * FROM posts WHERE id = ?', [post.id]);
        await cache.set(`post:${post.id}`, data, 3600);
    }

    console.log(`Cached ${popular.length} popular posts`);
}

// Run on server start
warmCache().catch(console.error);

// Or schedule regularly
setInterval(warmCache, 3600000); // Every hour

Cache Metrics and Monitoring

Track cache performance:

class MonitoredCache {
    constructor(cache) {
        this.cache = cache;
        this.metrics = {
            hits: 0,
            misses: 0,
            sets: 0,
            deletes: 0
        };
    }

    async get(key) {
        const value = await this.cache.get(key);

        if (value) {
            this.metrics.hits++;
        } else {
            this.metrics.misses++;
        }

        return value;
    }

    async set(key, value, ttl) {
        this.metrics.sets++;
        return this.cache.set(key, value, ttl);
    }

    async del(key) {
        this.metrics.deletes++;
        return this.cache.del(key);
    }

    getHitRate() {
        const total = this.metrics.hits + this.metrics.misses;
        return total > 0 ? (this.metrics.hits / total) * 100 : 0;
    }

    getMetrics() {
        return {
            ...this.metrics,
            hitRate: this.getHitRate().toFixed(2) + '%'
        };
    }
}

const cache = new MonitoredCache(redisCache);

// Expose metrics endpoint
app.get('/metrics/cache', (req, res) => {
    res.json(cache.getMetrics());
});

// Log metrics periodically
setInterval(() => {
    console.log('Cache metrics:', cache.getMetrics());
}, 60000);

Cache Invalidation Strategies

Time-Based (TTL)

// Simple expiration
await cache.set(key, value, 3600); // Expire after 1 hour

Event-Based

// Invalidate on update
async function updatePost(postId, data) {
    await db.query('UPDATE posts SET ? WHERE id = ?', [data, postId]);

    // Invalidate related caches
    await cache.del(`post:${postId}`);
    await cache.del(`user:${data.userId}:posts`);
    await cache.del('posts:recent');
}

Pattern-Based

// Invalidate all user-related caches
async function invalidateUser(userId) {
    const pattern = `user:${userId}:*`;

    // Redis SCAN to find matching keys
    let cursor = '0';
    do {
        const [newCursor, keys] = await redis.scan(
            cursor,
            'MATCH',
            pattern,
            'COUNT',
            100
        );
        cursor = newCursor;

        if (keys.length > 0) {
            await redis.del(...keys);
        }
    } while (cursor !== '0');
}

Real-World Caching Setup

My production configuration:

// 1. In-memory cache for hot data
const memCache = new MemoryCache();

// 2. Redis for shared cache
const redis = new Redis({
    host: process.env.REDIS_HOST,
    port: 6379,
    password: process.env.REDIS_PASSWORD,
    retryStrategy: (times) => Math.min(times * 50, 2000)
});
const redisCache = new RedisCache(redis);

// 3. Multi-level cache
const cache = new MultiLevelCache(memCache, redisCache);

// 4. Cache user data (1 hour)
async function getUser(userId) {
    return getWithSWR(
        `user:${userId}`,
        () => db.query('SELECT * FROM users WHERE id = ?', [userId]),
        3600,  // Fresh for 1 hour
        7200   // Stale acceptable for 2 hours
    );
}

// 5. Cache API responses (5 minutes)
app.get('/api/posts', async (req, res) => {
    const cacheKey = `posts:${req.query.page || 1}`;
    let posts = await cache.get(cacheKey);

    if (!posts) {
        posts = await db.query('SELECT * FROM posts LIMIT 20 OFFSET ?', [
            (req.query.page - 1) * 20
        ]);
        await cache.set(cacheKey, posts, 300);
    }

    // CDN caching too
    res.set('Cache-Control', 'public, max-age=60');
    res.json(posts);
});

// 6. Monitor cache performance
setInterval(() => {
    const memUsage = process.memoryUsage();
    const cacheSize = memCache.size();

    console.log({
        cacheSize,
        memoryMB: Math.round(memUsage.heapUsed / 1024 / 1024),
        hitRate: cache.getHitRate()
    });
}, 60000);

Performance Impact

Real numbers from my production app:

Without cache:

  • Dashboard load: 847ms
  • Database queries: 12 per request
  • CPU usage: 85%
  • Database connections: 45/50

With Redis cache:

  • Dashboard load: 15ms (56x faster)
  • Database queries: 0.1 per request (99% cache hit)
  • CPU usage: 12%
  • Database connections: 5/50

Cost savings:

  • Reduced from 3 database instances to 1
  • Monthly cost: $450 → $150 (67% reduction)

The Bottom Line

Caching is the highest-leverage performance optimization:

Use in-memory for single-server apps. Simple and fast.

Use Redis for multi-server deployments. Shared state, persistence.

Use CDN for static assets and public API responses.

Start with cache-aside pattern. Simplest and works for 90% of cases.

Monitor cache hit rates. Below 80% means your cache strategy needs work.

Invalidate aggressively. Stale data causes bugs. When in doubt, invalidate.

One afternoon of caching work made my app 56x faster and reduced costs by 67%. Do it early, before performance becomes a problem.