Caching Strategies for Modern Applications: When and How to Cache

Caching Strategies for Modern Applications: When and How to Cache

BySanjay Goraniya
3 min read
Share:

Caching Strategies for Modern Applications: When and How to Cache

Caching is one of the most effective ways to improve application performance. A well-implemented cache can reduce response times by orders of magnitude and significantly decrease database load. After implementing caching in production systems serving millions of requests, I've learned what works and what doesn't.

Why Cache?

Benefits

  • Faster responses - Serve data from memory
  • Reduced database load - Fewer queries
  • Better scalability - Handle more traffic
  • Cost savings - Less database resources needed
  • Improved user experience - Lower latency

Types of Caching

1. Application-Level Caching

In-memory caching within your application:

Code
// Simple in-memory cache
const cache = new Map();

async function getUser(userId) {
  // Check cache first
  if (cache.has(userId)) {
    return cache.get(userId);
  }
  
  // Fetch from database
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  
  // Store in cache
  cache.set(userId, user);
  
  return user;
}

2. Distributed Caching (Redis)

Shared cache across multiple servers:

Code
const redis = require('redis');
const client = redis.createClient();

async function getUser(userId) {
  // Check Redis cache
  const cached = await client.get(`user:${userId}`);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // Fetch from database
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  
  // Store in Redis (expire in 1 hour)
  await client.setEx(`user:${userId}`, 3600, JSON.stringify(user));
  
  return user;
}

3. HTTP Caching

Browser and CDN caching:

Code
// Set cache headers
app.get('/api/users/:id', (req, res) => {
  const user = getUser(req.params.id);
  
  res.set('Cache-Control', 'public, max-age=3600');
  res.set('ETag', generateETag(user));
  res.json(user);
});

4. Database Query Caching

Cache query results:

Code
async function getPopularProducts() {
  const cacheKey = 'popular-products';
  
  // Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // Execute query
  const products = await db.query(`
    SELECT * FROM products 
    WHERE views > 1000 
    ORDER BY views DESC 
    LIMIT 10
  `);
  
  // Cache for 5 minutes
  await redis.setEx(cacheKey, 300, JSON.stringify(products));
  
  return products;
}

Caching Patterns

1. Cache-Aside (Lazy Loading)

Application checks cache, loads from database if miss:

Code
async function getData(key) {
  // Check cache
  let data = await cache.get(key);
  
  if (!data) {
    // Cache miss - load from database
    data = await database.get(key);
    
    // Store in cache
    await cache.set(key, data, { ttl: 3600 });
  }
  
  return data;
}

Use when: Read-heavy workloads, data changes infrequently

2. Write-Through

Write to cache and database simultaneously:

Code
async function updateData(key, value) {
  // Write to database
  await database.update(key, value);
  
  // Write to cache
  await cache.set(key, value, { ttl: 3600 });
}

Use when: Consistency is critical

3. Write-Back (Write-Behind)

Write to cache first, write to database asynchronously:

Code
async function updateData(key, value) {
  // Write to cache immediately
  await cache.set(key, value, { ttl: 3600 });
  
  // Write to database asynchronously
  setImmediate(async () => {
    await database.update(key, value);
  });
}

Use when: Write performance is critical, can tolerate some data loss

4. Refresh-Ahead

Proactively refresh cache before expiration:

Code
async function getData(key) {
  let data = await cache.get(key);
  
  if (!data) {
    // Cache miss - load from database
    data = await database.get(key);
    await cache.set(key, data, { ttl: 3600 });
  } else {
    // Check if near expiration
    const ttl = await cache.ttl(key);
    if (ttl < 300) { // Less than 5 minutes left
      // Refresh in background
      setImmediate(async () => {
        const fresh = await database.get(key);
        await cache.set(key, fresh, { ttl: 3600 });
      });
    }
  }
  
  return data;
}

Use when: Data must always be fresh, can't wait for cache miss

What to Cache

Good Candidates

  • User sessions - Frequently accessed, rarely change
  • Product catalogs - Read-heavy, changes infrequently
  • Configuration data - Rarely changes
  • Computed results - Expensive calculations
  • API responses - External API calls

Bad Candidates

  • Frequently changing data - Cache invalidation overhead
  • Large objects - Memory constraints
  • Sensitive data - Security concerns
  • Real-time data - Must be fresh
  • Unique data - No reuse benefit

Cache Invalidation

Time-Based Expiration

Code
// Set expiration time
await cache.set('key', 'value', { ttl: 3600 }); // 1 hour

Event-Based Invalidation

Code
// Invalidate on update
async function updateUser(userId, data) {
  await db.query('UPDATE users SET ... WHERE id = $1', [userId]);
  
  // Invalidate cache
  await cache.del(`user:${userId}`);
}

Tag-Based Invalidation

Code
// Cache with tags
await cache.set('user:123', userData, { tags: ['user', 'profile'] });

// Invalidate all items with tag
await cache.invalidateTags(['user']);

Cache Keys

Good Key Design

Code
// Hierarchical keys
`user:${userId}`
`user:${userId}:profile`
`user:${userId}:orders`
`product:${productId}`
`product:${productId}:reviews`

// Versioned keys
`user:${userId}:v2`

Avoid

Code
// Bad: Unclear keys
`data1`
`temp`
`cache`

Real-World Example

Challenge: E-commerce site, product page taking 2+ seconds, database overloaded.

Solution: Multi-layer caching

Code
// Layer 1: Application cache (hot data)
const hotCache = new Map(); // In-memory

// Layer 2: Redis (warm data)
const redis = require('redis');
const redisClient = redis.createClient();

// Layer 3: Database (cold data)

async function getProduct(productId) {
  // Check hot cache (in-memory)
  if (hotCache.has(productId)) {
    return hotCache.get(productId);
  }
  
  // Check Redis cache
  const cached = await redisClient.get(`product:${productId}`);
  if (cached) {
    const product = JSON.parse(cached);
    // Promote to hot cache
    hotCache.set(productId, product);
    return product;
  }
  
  // Load from database
  const product = await db.query(
    'SELECT * FROM products WHERE id = $1',
    [productId]
  );
  
  // Store in both caches
  hotCache.set(productId, product);
  await redisClient.setEx(
    `product:${productId}`,
    3600,
    JSON.stringify(product)
  );
  
  return product;
}

Result:

  • Response time: 2s → 50ms (40x improvement)
  • Database load: Reduced by 80%
  • User experience: Significantly improved

Best Practices

  1. Cache at the right level - Application, database, HTTP
  2. Set appropriate TTLs - Balance freshness and performance
  3. Invalidate properly - Keep cache consistent
  4. Monitor cache hit rates - Measure effectiveness
  5. Handle cache failures - Don't break if cache is down
  6. Use consistent keys - Easy to manage
  7. Consider cache size - Memory constraints
  8. Test cache behavior - Ensure it works correctly

Common Pitfalls

1. Cache Stampede

Problem: Many requests miss cache simultaneously

Solution: Use locks or probabilistic early expiration

2. Stale Data

Problem: Cache not invalidated on updates

Solution: Invalidate on writes

3. Memory Exhaustion

Problem: Cache grows unbounded

Solution: Set size limits, use LRU eviction

4. Cache Warming

Problem: Cold cache on restart

Solution: Pre-populate cache on startup

Conclusion

Caching is a powerful tool for improving performance, but it requires careful implementation. The key is to:

  • Cache the right data - Frequently accessed, rarely changed
  • Use appropriate patterns - Cache-aside, write-through, etc.
  • Invalidate properly - Keep data fresh
  • Monitor and optimize - Measure hit rates, adjust strategy

Remember: Caching is a trade-off between freshness and performance. Find the right balance for your use case.

What caching strategies have you implemented? What challenges have you faced?

Share:

Related Posts