Avancé25 min de lecture

Caching Strategies

Boost API performance with caching — in-memory cache, Redis, HTTP cache headers, and cache invalidation.

Why Cache?

Database queries are slow. Even a well-optimized query takes milliseconds, and complex queries with joins, aggregations, or full-text search can take hundreds of milliseconds or even seconds. Now multiply that by thousands of concurrent users. If 1,000 users request the same product listing page within a minute, that is 1,000 identical database queries returning the exact same data. Your database is doing the same work over and over again for no reason.

Caching solves this by storing the result of an expensive operation and serving that stored result on subsequent requests. Instead of querying the database 1,000 times, you query it once, store the result in a fast cache, and serve the cached result to the other 999 requests. The cache lookup is orders of magnitude faster than the original operation — sub-millisecond reads compared to multi-millisecond database queries.

The benefits of caching are substantial and measurable:

  • Faster response times — Cache reads are sub-millisecond. Users experience near-instant page loads instead of waiting for database queries.
  • Reduced database load — Instead of hammering your database with thousands of identical queries, you hit it once and cache the result. This frees up database connections for queries that actually need fresh data.
  • Lower infrastructure costs — If your database can handle the load because caching absorbs most reads, you do not need to scale to a larger (and more expensive) database instance.
  • Better user experience — Speed directly impacts engagement. Amazon found that every 100ms of latency costs them 1% in sales. Google found that a 0.5-second delay in search results caused a 20% drop in traffic.
  • Resilience — If your database goes down briefly, cached data can still serve read requests, providing a degraded but functional experience.

There is a famous quote in computer science: "There are only two hard things in Computer Science: cache invalidation and naming things." Caching itself is simple — the hard part is knowing when to throw away cached data because the underlying data has changed. We will cover that in detail in this lesson.

In-Memory Caching

The simplest caching approach is in-memory caching — storing data in a JavaScript Map or plain object within your Node.js process. No external dependencies, no network calls, no setup. Just a variable that holds data.

The pattern is straightforward:

javascript
const cache = new Map();

async function getProducts() {
  // 1. Check cache
  if (cache.has('products')) {
    return cache.get('products'); // Cache HIT — return instantly
  }

  // 2. Cache MISS — query database
  const products = await db.query('SELECT * FROM products');

  // 3. Store in cache for next time
  cache.set('products', products);

  return products;
}

This turns 1,000 database queries into 1 database query and 999 instant cache lookups. The first request takes the normal database time; every subsequent request returns in microseconds.

Advantages of in-memory caching:

  • Extremely fast — no network overhead, data is right there in your process memory
  • Zero dependencies — no Redis, no external services, no configuration
  • Simple to implement — just a Map and an if/else

Disadvantages of in-memory caching:

  • Lost on restart — When your Node.js process restarts (deploy, crash, auto-scaling), the cache is gone. Every request is a cache miss until the cache warms up again.
  • Not shared across instances — If you run 4 Node.js instances behind a load balancer (which you should in production), each instance has its own separate cache. User A hits instance 1 (cache miss, queries DB), user B hits instance 2 (another cache miss, queries DB again). The same data is cached 4 times, and cache invalidation must happen on all 4 instances.
  • Memory limited — Your cache lives in your Node.js process memory. Node.js defaults to ~1.5 GB of heap memory. If you cache too much data, you will get out-of-memory errors and your process will crash.

In-memory caching is good for: small datasets (configuration, feature flags, lookup tables), single-server applications, data that rarely changes, and development/prototyping. For anything larger or production-critical, you need an external cache like Redis.

Redis — External Cache

Redis (Remote Dictionary Server) is an in-memory data store that sits between your API and your database as a dedicated caching layer. It solves every problem that in-memory caching has: it persists across restarts, is shared across all server instances, and can hold far more data than a single Node.js process.

Redis stores data in memory (like your JavaScript Map), but it runs as a separate process — a dedicated server optimized for key-value lookups. It supports sub-millisecond reads and writes, can handle hundreds of thousands of operations per second, and provides rich data structures beyond simple key-value pairs: strings, hashes, lists, sets, sorted sets, and streams.

Install the Redis client: npm install redis. The code pattern is the same check-or-query flow, but with Redis instead of a local Map:

javascript
const redis = require('redis');
const client = redis.createClient({ url: 'redis://localhost:6379' });
await client.connect();

async function getProducts() {
  // 1. Check Redis cache
  const cached = await client.get('products');
  if (cached) {
    return JSON.parse(cached); // Cache HIT
  }

  // 2. Cache MISS — query database
  const products = await db.query('SELECT * FROM products');

  // 3. Store in Redis with TTL (time-to-live)
  await client.setEx('products', 3600, JSON.stringify(products));
  // 'products' = key, 3600 = expires in 1 hour, JSON string = value

  return products;
}

TTL (Time-To-Live) is critical. setEx stores the data with an automatic expiration. After 3600 seconds (1 hour), Redis automatically deletes the key. The next request will be a cache miss, triggering a fresh database query. TTL is your simplest cache invalidation strategy — you accept that data might be up to 1 hour stale, but in exchange you get massive performance gains.

Redis use cases in production: session storage (shared across server instances), API response caching, rate limiting counters, real-time leaderboards (sorted sets), job queues (lists), pub/sub messaging, and geographic queries (geospatial indexes). It is one of the most versatile tools in a backend developer's toolkit.

Popular managed Redis services include AWS ElastiCache, Redis Cloud, Upstash (serverless Redis with per-request pricing), and Railway (easy setup for small projects).

Cache Invalidation Strategies

When the underlying data changes, the cache becomes stale — it holds an outdated version of the data. Serving stale data is sometimes acceptable (a product list that is 5 minutes old is probably fine), but sometimes it is not (a user's account balance must always be current). Cache invalidation is the process of removing or updating stale cache entries.

There are four main cache invalidation strategies:

1. TTL (Time-Based Expiry) — The simplest strategy. Set an expiration time on every cache entry. After the TTL expires, the entry is automatically removed, and the next request fetches fresh data from the database. Pros: dead simple, no code needed for invalidation. Cons: data can be stale for up to the TTL duration. Good for: product listings, blog posts, public data that does not need to be real-time.

2. Write-Through Cache — When you write to the database, you also immediately write to the cache. The cache is always up-to-date because every database write is mirrored to the cache in the same operation. Pros: cache is always fresh. Cons: writes are slower (two operations instead of one), and you might cache data that is never read (wasting memory).

3. Write-Behind (Write-Back) — Writes go to the cache first, and the cache asynchronously writes to the database in the background. Pros: very fast writes. Cons: risk of data loss if the cache crashes before writing to the database. Used in specialized high-throughput scenarios.

4. Cache-Aside (Lazy Loading) — The application manages the cache explicitly. On read: check cache, if miss query DB and store in cache. On write: update DB and delete the cache entry (not update it — delete it, so the next read fetches fresh data). This is the most common pattern. Pros: only caches data that is actually read, simple to reason about. Cons: first request after invalidation is always a cache miss.

Practical cache invalidation approach: Use key naming conventions to make invalidation targeted. Instead of one big "products" key, use patterns like products:all, products:123, products:category:electronics. When product 123 is updated, delete products:123 and products:all (the list that contains it). When a new product is added to electronics, delete products:all and products:category:electronics. This way, you invalidate only what changed, not the entire cache.

javascript
// On product update:
await redis.del(`products:${productId}`);
await redis.del('products:all');
await redis.del(`products:category:${product.category}`);

Express Caching Middleware with TTL

javascript
const redis = require('redis');
const client = redis.createClient({ url: process.env.REDIS_URL });
client.connect();

// ── Caching Middleware ─────────────────────────────────
// Checks Redis before hitting the route handler
function cacheMiddleware(ttlSeconds = 3600) {
  return async (req, res, next) => {
    // Only cache GET requests (never cache POST/PUT/DELETE)
    if (req.method !== 'GET') return next();

    const cacheKey = `cache:${req.originalUrl}`;

    try {
      const cached = await client.get(cacheKey);
      if (cached) {
        console.log(`Cache HIT: ${cacheKey}`);
        return res.json(JSON.parse(cached));
      }
      console.log(`Cache MISS: ${cacheKey}`);

      // Override res.json to intercept the response and cache it
      const originalJson = res.json.bind(res);
      res.json = async (body) => {
        await client.setEx(cacheKey, ttlSeconds, JSON.stringify(body));
        return originalJson(body);
      };

      next();
    } catch (error) {
      // If Redis is down, skip caching and proceed normally
      console.error('Cache error:', error.message);
      next();
    }
  };
}

// ── Cache Invalidation Helper ─────────────────────────
async function invalidateCache(patterns) {
  for (const pattern of patterns) {
    const keys = await client.keys(pattern);
    if (keys.length > 0) {
      await client.del(keys);
      console.log(`Invalidated ${keys.length} keys matching: ${pattern}`);
    }
  }
}

// ── Usage in Routes ───────────────────────────────────
const express = require('express');
const app = express();

// GET routes use the cache middleware
app.get('/api/products', cacheMiddleware(300), async (req, res) => {
  const products = await Product.find(); // Only runs on cache miss
  res.json({ success: true, data: products });
});

// Write routes invalidate the cache
app.post('/api/products', async (req, res) => {
  const product = await Product.create(req.body);
  await invalidateCache(['cache:/api/products*']);
  res.status(201).json({ success: true, data: product });
});

app.put('/api/products/:id', async (req, res) => {
  const product = await Product.findByIdAndUpdate(req.params.id, req.body);
  await invalidateCache([
    `cache:/api/products/${req.params.id}`,
    'cache:/api/products',
  ]);
  res.json({ success: true, data: product });
});

HTTP Cache Headers

Server-side caching (in-memory, Redis) reduces database load, but the request still travels from the client to your server. HTTP cache headers take it a step further — they tell the client's browser (or a CDN) to cache the response, so subsequent requests never even reach your server.

The key HTTP cache headers:

Cache-Control — The most important header. Controls who can cache the response and for how long.

  • Cache-Control: public, max-age=3600 — Anyone (browser, CDN, proxy) can cache this for 1 hour
  • Cache-Control: private, max-age=600 — Only the user's browser can cache this (not CDNs). Use for user-specific data.
  • Cache-Control: no-store — Do NOT cache at all. Use for sensitive data (banking, health records).
  • Cache-Control: no-cache — Cache the response, but revalidate with the server before using it (check if it changed).

ETag — A hash or version identifier for the response content. The server sends ETag: "abc123" with the response. On subsequent requests, the browser sends If-None-Match: "abc123". If the content has not changed, the server responds with 304 Not Modified (no body), and the browser uses its cached copy. This saves bandwidth — instead of sending the entire response again, you send a tiny 304 response.

Last-Modified — A timestamp indicating when the resource was last changed. Works similarly to ETag: the browser sends If-Modified-Since, and the server returns 304 if the resource has not changed since that timestamp.

For APIs, follow these guidelines:

  • Cache GET responses for public, non-personalized data (product listings, blog posts, static configurations)
  • Use private for user-specific data (profile, settings, notifications)
  • Use no-store for sensitive data (payment info, tokens, personal health data)
  • Set max-age based on how fresh the data needs to be: 60 seconds for frequently changing data, 3600 for hourly data, 86400 for daily data
  • Always set Vary: Authorization if the response depends on who is logged in — this prevents a CDN from serving user A's cached response to user B
javascript
// Express: set cache headers
app.get('/api/products', (req, res) => {
  res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
  res.json(products);
});

app.get('/api/me', authMiddleware, (req, res) => {
  res.set('Cache-Control', 'private, no-cache');
  res.json(req.user);
});

When combined with a CDN (Cloudflare, AWS CloudFront, Vercel Edge), HTTP cache headers become extremely powerful. The CDN caches your API responses at edge locations around the world, serving them to users in milliseconds without ever hitting your server.

What does TTL stand for in caching?

Prêt à pratiquer ?

Crée ton compte gratuit pour accéder à l'éditeur de code interactif, lancer les défis et suivre ta progression.