Skip to content

Cache Project: Build a Multi-Layer Caching System from Scratch

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Cache Project: Build a Multi. We cover key concepts, practical examples, and best practices to help you master this topic.

This project brings together everything you have learned about Caching. You will build a multi-layer caching system for a product catalog API that uses an in-memory L1 cache, a Redis L2 cache, CDN-like cache headers, and event-driven cache invalidation.

flowchart TB
    Client[Client] --> LB[Load Balancer]
    LB --> Nginx[Nginx CDN Simulator]
    Nginx --> App1[App Instance 1]
    Nginx --> App2[App Instance 2]
    App1 --> L1[L1: In-Memory Cache]
    App2 --> L1
    L1 --> L2[L2: Redis Cache]
    L2 --> DB[(PostgreSQL)]
    App1 --> Invalidator[Cache Invalidator]
    App2 --> Invalidator
    Invalidator --> Redis_PubSub[Redis Pub/Sub]
    Redis_PubSub --> L1
    Redis_PubSub --> L2

Project Requirements

Build the following components:

1. Multi-Layer Cache

class MultiLayerCache {
  constructor() {
    this.l1 = new LRU.LRUCache({ max: 500, maxAge: 30000 });
    this.l2 = redisClient;
    this.stats = { l1Hits: 0, l2Hits: 0, misses: 0 };
  }

  async get(key, fetchFn) {
    // L1 check
    const l1Result = this.l1.get(key);
    if (l1Result !== undefined) {
      this.stats.l1Hits++;
      return l1Result;
    }

    // L2 check
    const l2Result = await this.l2.get(key);
    if (l2Result) {
      this.stats.l2Hits++;
      const parsed = JSON.parse(l2Result);
      this.l1.set(key, parsed);
      return parsed;
    }

    // Miss: fetch from source
    this.stats.misses++;
    const data = await fetchFn();
    if (data) {
      await this.l2.setEx(key, 3600, JSON.stringify(data));
      this.l1.set(key, data);
    }
    return data;
  }

  async invalidate(key) {
    this.l1.delete(key);
    await this.l2.del(key);
  }

  stats() {
    const total = this.stats.l1Hits + this.stats.l2Hits + this.stats.misses;
    return {
      l1HitRate: total > 0 ? (this.stats.l1Hits / total * 100).toFixed(1) + '%' : '0%',
      l2HitRate: total > 0 ? (this.stats.l2Hits / total * 100).toFixed(1) + '%' : '0%',
      overallHitRate: total > 0 ? ((this.stats.l1Hits + this.stats.l2Hits) / total * 100).toFixed(1) + '%' : '0%',
      totalRequests: total
    };
  }
}

Expected output:

L1 hit rate: 65.3%, L2 hit rate: 30.1%, overall: 95.4%. Total requests: 10000.

2. Event-Driven Invalidation

class ProjectInvalidator {
  constructor(redisClient) {
    this.redis = redisClient;
    this.subscriber = redisClient.duplicate();
    this.listeners = new Map();
  }

  async connect() {
    await this.subscriber.subscribe('cache:invalidate');
    this.subscriber.on('message', (channel, message) => {
      const { key, pattern } = JSON.parse(message);
      const handler = this.listeners.get(pattern || key);
      if (handler) handler(key);
    });
  }

  async invalidate(key) {
    await this.redis.publish('cache:invalidate', JSON.stringify({ key }));
  }

  onInvalidate(pattern, handler) {
    this.listeners.set(pattern, handler);
  }
}

Expected output:

When a product is updated, the API calls invalidator.invalidate('product:123'). All nodes receive the invalidation event and clear their L1 and L2 caches.

3. CDN-Simulated Nginx Configuration

proxy_cache_path /tmp/nginx-cache levels=1:2 keys_zone=cdn_cache:10m max_size=500m;

server {
  listen 8080;

  location /api/ {
    proxy_cache cdn_cache;
    proxy_cache_key "$scheme$request_method$host$request_uri";
    proxy_cache_valid 200 60s;
    proxy_cache_valid 404 10s;
    proxy_cache_use_stale error timeout updating;
    add_header X-CDN-Cache $upstream_cache_status;
    proxy_pass http://node_app:3000;
  }

  location /purge/ {
    proxy_cache_purge PURGE from 127.0.0.1;
  }
}

Expected output:

First request: X-CDN-Cache: MISS. Subsequent requests within 60s: X-CDN-Cache: HIT. PURGE requests clear the cache for a path.

4. Cache Warming on Startup

async function warmCache(api, popularIds) {
  console.log(`Warming cache with ${popularIds.length} popular products...`);
  const results = await Promise.allSettled(
    popularIds.map(id =>
      fetch(`${api}/products/${id}`).then(r => r.json())
    )
  );
  const warmed = results.filter(r => r.status === 'fulfilled').length;
  console.log(`Cache warmed: ${warmed}/${popularIds.length} products cached`);
}

Expected output:

Warming cache with 100 popular products...
Cache warmed: 100/100 products cached

Acceptance Criteria

  1. In-memory L1 cache serves hot keys in <1ms
  2. Redis L2 cache serves warm keys in <5ms
  3. Overall cache hit rate > 90% under sustained load
  4. Invalidation propagates to all instances within 100ms
  5. Cache warming completes within 5 seconds on startup
  6. CDN simulator shows MISS on first request, HIT on subsequent
  7. Power outage simulation: recovering 4 instances shows graceful degradation

Testing

const http = require('http');

async function runLoadTest() {
  const products = Array.from({ length: 100 }, (_, i) => i + 1);
  const results = { l1Hits: 0, l2Hits: 0, misses: 0, errors: 0, latencies: [] };

  const promises = [];
  for (let i = 0; i < 5000; i++) {
    const id = products[Math.floor(Math.random() * products.length)];
    promises.push(
      measureRequest(`http://localhost:3000/api/products/${id}`)
        .then(r => {
          results[r.cacheType === 'l1' ? 'l1Hits' : r.cacheType === 'l2' ? 'l2Hits' : 'misses']++;
          results.latencies.push(r.latency);
        })
        .catch(() => results.errors++)
    );
  }

  await Promise.all(promises);

  const total = results.l1Hits + results.l2Hits + results.misses;
  const avgLat = results.latencies.reduce((a, b) => a + b, 0) / results.latencies.length;

  console.log(`Results: ${total} requests`);
  console.log(`L1 Hits: ${results.l1Hits} (${(results.l1Hits/total*100).toFixed(1)}%)`);
  console.log(`L2 Hits: ${results.l2Hits} (${(results.l2Hits/total*100).toFixed(1)}%)`);
  console.log(`Misses: ${results.misses} (${(results.misses/total*100).toFixed(1)}%)`);
  console.log(`Avg Latency: ${avgLat.toFixed(3)}ms`);
}

function measureRequest(url) {
  const start = Date.now();
  return fetch(url).then(res =>
    res.json().then(() => ({
      latency: Date.now() - start,
      cacheType: res.headers.get('X-Cache-Layer') || 'miss'
    }))
  );
}

Expected output:

Results: 5000 requests
L1 Hits: 3254 (65.1%)
L2 Hits: 1520 (30.4%)
Misses: 226 (4.5%)
Avg Latency: 1.234ms

Common Mistakes

  • Skipping cache warming — first requests after deploy are slow and may timeout under load.
  • Not testing invalidation under concurrent writes — race conditions can leave stale data in the cache.
  • Using the same TTL for all layers — L1 should have shorter TTL than L2.
  • Forgetting to handle cache server failures — implement circuit breakers for L2.
  • Not monitoring cache metrics during the project — you won't know if your cache is effective.

FAQ

How many layers of cache should I use?

Two layers (L1 in-memory + L2 Redis) is sufficient for most applications. Adding a third layer (CDN) is beneficial for geographically distributed users.

What cache size should I configure for L1?

Start with 500-1000 entries or 10% of your working set. Monitor L1 hit rate: if below 50%, increase size. If above 90%, you may be over-provisioning.

How do I test cache invalidation?

Write a test that: (1) fetches a resource (cache miss), (2) fetches again (cache hit), (3) updates the resource and invalidates, (4) fetches again (should be miss or updated data).

Should L1 and L2 have the same TTL?

No. L1 should have a shorter TTL (30-60s) because it holds fewer entries and stale data is more visible. L2 can have longer TTLs (1-24h).

How do I handle cache stampede at the L1 level?

Use request coalescing in the L1 layer: if multiple threads miss L1 simultaneously, only one fetches from L2. Also use jittered TTLs for L1 entries.

Submission Checklist

  • Multi-layer cache (L1 + L2) implemented
  • Cache warming on startup
  • Event-driven invalidation with Redis pub/sub
  • CDN simulation with Nginx
  • Metrics endpoint exposing hit rates and latency
  • Load test demonstrating >90% hit rate
  • Graceful degradation when Redis is unavailable
  • Cache stampede protection

What's Next

Congratulations on completing the caching strategies module. Continue to Backend Security to learn how to protect your backend from common vulnerabilities and attacks.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro