0Pricing
Node.js Backend Development Bootcamp · Lesson

Cache-Aside, Write-Through, and TTL Strategies

Choose the right caching pattern and expiration policy to balance freshness against database load.

Cache-Aside, Write-Through, and TTL Strategies is a free Node.js Backend Development Bootcamp lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Node.js Backend Development Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why Caching Exists: The Database Bottleneck

Every Node.js backend eventually hits the same wall: the database becomes the bottleneck. As traffic grows, repeated reads for the same data — user profiles, product listings, config values — hammer your DB with identical queries.

Caching solves this by storing the result of an expensive operation so subsequent requests can be served from a fast in-memory store instead of hitting the database again.

  • Redis is the industry-standard caching layer for Node.js — it operates entirely in memory, supports rich data structures, and handles hundreds of thousands of operations per second.
  • A cache hit returns data in under 1ms; a DB query can take 10–200ms or more under load.
  • The trade-off is freshness: cached data may not reflect the latest DB state.

Choosing the right caching pattern determines how well your system balances speed against consistency.

Connecting to Redis with ioredis

Before implementing any caching pattern, you need a reliable Redis client. ioredis is the most popular choice for Node.js — it supports clustering, Sentinel, pipelining, and Lua scripting out of the box.

Install it and create a reusable client instance:

  • Use a singleton module so every part of your app shares one connection pool.
  • Configure maxRetriesPerRequest and lazyConnect for production resilience.
  • The client emits connect, error, and reconnecting events — always handle errors.
// redis/client.js
const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD || undefined,
  maxRetriesPerRequest: 3,
  lazyConnect: true,
});

redis.on('connect', () => console.log('[Redis] Connected'));
redis.on('error', (err) => console.error('[Redis] Error:', err.message));
redis.on('reconnecting', () => console.log('[Redis] Reconnecting...'));

module.exports = redis;

The Cache-Aside Pattern (Lazy Loading)

Cache-Aside (also called lazy loading) is the most common caching pattern. The application itself manages the cache — data is only loaded into the cache when it is first requested.

The flow is straightforward:

  • Read: Check the cache first. On a cache hit, return the cached value. On a cache miss, query the database, store the result in the cache, then return it.
  • Write: Update the database, then invalidate (delete) the cache entry so the next read fetches fresh data.

Cache-Aside is resilient — if Redis goes down, your app degrades gracefully by falling back to the DB. It also prevents loading unused data (no wasted memory).

The downside: the first request after a cache miss is always slow. Under high concurrency, multiple requests can trigger simultaneous DB queries for the same key — this is called a cache stampede.

Implementing Cache-Aside in Node.js

Here is a practical implementation of Cache-Aside for a user profile lookup. Notice the pattern: GET → miss? → DB query → SET → return.

Key decisions shown in the code:

  • Cache key includes the entity type and ID to avoid namespace collisions.
  • EX sets a TTL (time-to-live) in seconds — data auto-expires even if never explicitly invalidated.
  • JSON serialization is needed because Redis stores strings.
  • On cache miss, DB result is written back before returning.
// services/userService.js
const redis = require('../redis/client');
const db = require('../db'); // hypothetical DB module

const USER_CACHE_TTL = 300; // 5 minutes

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

  // 1. Try cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log(`[Cache HIT] ${cacheKey}`);
    return JSON.parse(cached);
  }

  // 2. Cache miss — query DB
  console.log(`[Cache MISS] ${cacheKey}`);
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  if (!user) return null;

  // 3. Populate cache with TTL
  await redis.set(cacheKey, JSON.stringify(user), 'EX', USER_CACHE_TTL);

  return user;
}

async function updateUser(userId, data) {
  // Update DB first
  await db.query('UPDATE users SET name=$1 WHERE id=$2', [data.name, userId]);

  // Invalidate cache so next read fetches fresh data
  await redis.del(`user:${userId}`);
  console.log(`[Cache INVALIDATED] user:${userId}`);
}

module.exports = { getUserById, updateUser };

The Write-Through Pattern

Write-Through takes the opposite approach to cache invalidation: whenever data is written to the database, it is simultaneously written to the cache. The cache always mirrors the DB state.

Characteristics of Write-Through:

  • Reads are always fast — the cache is guaranteed to have current data after every write.
  • No stale data — cache and DB are updated atomically in the same write operation.
  • Write penalty — every write incurs two round-trips (DB + cache), slightly increasing write latency.
  • Cold start problem — data written before Redis was introduced won't be in the cache; combine with Cache-Aside reads for a warm-up effect.

Write-Through is ideal when read-to-write ratios are high and you cannot tolerate stale reads (e.g., inventory counts, pricing data, session state).

Implementing Write-Through in Node.js

With Write-Through, the service layer writes to both the DB and the cache in every mutation. Notice that unlike Cache-Aside, the cache is populated on write — not on read.

The example below shows a product price update where stale reads would cause real financial harm:

// services/productService.js
const redis = require('../redis/client');
const db = require('../db');

const PRODUCT_TTL = 3600; // 1 hour

async function updateProductPrice(productId, newPrice) {
  // 1. Write to database
  const updated = await db.query(
    'UPDATE products SET price=$1, updated_at=NOW() WHERE id=$2 RETURNING *',
    [newPrice, productId]
  );

  // 2. Write-through: update cache immediately
  const cacheKey = `product:${productId}`;
  await redis.set(cacheKey, JSON.stringify(updated), 'EX', PRODUCT_TTL);
  console.log(`[Write-Through] ${cacheKey} updated in cache`);

  return updated;
}

async function getProduct(productId) {
  const cacheKey = `product:${productId}`;

  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log(`[Cache HIT] ${cacheKey}`);
    return JSON.parse(cached);
  }

  // Cold-start fallback (Cache-Aside hybrid)
  console.log(`[Cache MISS - Cold Start] ${cacheKey}`);
  const product = await db.query('SELECT * FROM products WHERE id=$1', [productId]);
  if (product) {
    await redis.set(cacheKey, JSON.stringify(product), 'EX', PRODUCT_TTL);
  }
  return product;
}

module.exports = { updateProductPrice, getProduct };

TTL Strategies: How Long Should Data Live?

TTL (Time-To-Live) is the expiration window for a cache entry. Getting it right is critical — too short and you hammer the DB; too long and users see stale data.

Common TTL tiers by data volatility:

  • Static config / feature flags: 3600s–86400s (1 hour to 1 day). Changes rarely, expensive to compute.
  • User profiles / preferences: 300s–900s (5–15 minutes). Updated occasionally; short staleness acceptable.
  • Product listings / search results: 60s–300s (1–5 minutes). Moderate change rate.
  • Shopping cart / session data: 1800s–3600s (30 min to 1 hour). Should survive browser refresh but expire naturally.
  • Real-time data (stock prices, live scores): 1s–10s or NO cache — fetch fresh every time.

A practical rule: start with a conservative short TTL, measure cache hit rates, then extend TTL only where the hit rate justifies it.

// config/cacheTTL.js
// Centralize TTL constants — one place to tune
module.exports = Object.freeze({
  USER_PROFILE:    5  * 60,       //  5 minutes
  PRODUCT_DETAIL:  3  * 60,       //  3 minutes
  PRODUCT_LIST:    1  * 60,       //  1 minute
  FEATURE_FLAGS:   60 * 60,       //  1 hour
  SESSION:         30 * 60,       // 30 minutes
  LEADERBOARD:     10,            // 10 seconds
});

// Usage example:
// const TTL = require('../config/cacheTTL');
// redis.set(key, value, 'EX', TTL.USER_PROFILE);

Sliding vs. Fixed TTL Windows

There are two ways to manage expiration timing:

  • Fixed TTL: The timer starts when the key is SET and counts down regardless of access. Simple and predictable — use for data that must expire by a wall-clock deadline (e.g., session tokens, rate-limit windows).
  • Sliding TTL (refresh-on-read): Each time the key is read, its TTL resets. Active users keep their cache warm; idle entries expire naturally. Use for frequently-accessed hot data.

Redis does not have native sliding TTL — you implement it by calling EXPIRE on every cache hit to reset the countdown.

// Sliding TTL — reset expiry on every successful cache read
async function getUserWithSlidingTTL(userId) {
  const cacheKey = `user:${userId}`;
  const SLIDING_TTL = 300; // 5-minute inactivity window

  const cached = await redis.get(cacheKey);
  if (cached) {
    // Reset TTL on each access — extends life for active users
    await redis.expire(cacheKey, SLIDING_TTL);
    return JSON.parse(cached);
  }

  const user = await db.query('SELECT * FROM users WHERE id=$1', [userId]);
  if (user) {
    await redis.set(cacheKey, JSON.stringify(user), 'EX', SLIDING_TTL);
  }
  return user;
}

Preventing Cache Stampedes with Mutex Locks

A cache stampede occurs when a popular key expires and hundreds of concurrent requests all experience a cache miss at the same moment — each fires a DB query, overloading your database.

The standard solution is a distributed mutex: only the first request acquires a lock and fetches from DB. The others either wait or serve a slightly stale value.

Redis supports atomic lock acquisition via SET key value NX EX timeout:

  • NX — only set if the key does NOT exist (atomic compare-and-set).
  • EX — auto-release after timeout so locks can't be held indefinitely.
  • Non-lock-holders retry after a short sleep until the lock is released and the cache is warm.
// utils/cacheWithLock.js
const redis = require('../redis/client');

async function getOrFetchWithLock(cacheKey, fetchFn, ttl = 60) {
  // 1. Try cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

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

  if (lockAcquired) {
    try {
      // 2. We hold the lock — fetch from DB
      const data = await fetchFn();
      await redis.set(cacheKey, JSON.stringify(data), 'EX', ttl);
      return data;
    } finally {
      await redis.del(lockKey); // Always release lock
    }
  } else {
    // 3. Lock held by another request — wait and retry
    await new Promise((r) => setTimeout(r, 50));
    const retried = await redis.get(cacheKey);
    return retried ? JSON.parse(retried) : fetchFn(); // fallback
  }
}

module.exports = { getOrFetchWithLock };

Cache Invalidation Patterns

Phil Karlton famously said: "There are only two hard things in computer science: cache invalidation and naming things." Getting invalidation right keeps your cache consistent without over-flushing.

The main approaches:

  • Key-based invalidation (DEL): Delete the exact key when underlying data changes. Fast and surgical — use this for single-entity mutations.
  • Prefix-based invalidation (SCAN + DEL): Find and delete all keys matching a pattern. Useful when one write invalidates many related cache entries (e.g., updating a category invalidates all product-list caches).
  • TTL-only (no explicit invalidation): Let data expire naturally. Acceptable when brief staleness is tolerable and writes are infrequent.
  • Cache tagging: Associate multiple cache keys with a tag; invalidate by tag. Requires a set-of-keys index in Redis.
// Prefix-based invalidation using SCAN (safe for production, non-blocking)
async function invalidateProductListCaches(categoryId) {
  const pattern = `products:list:category:${categoryId}:*`;
  let cursor = '0';
  let deleted = 0;

  do {
    // SCAN is non-blocking unlike KEYS — safe for production
    const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
    cursor = nextCursor;

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

  console.log(`[Cache] Invalidated ${deleted} product-list keys for category ${categoryId}`);
}

// Call after a category rename or product re-assignment:
// await invalidateProductListCaches(42);

Choosing the Right Pattern: Decision Framework

No single pattern fits all use cases. Here is a quick decision framework:

  • Cache-Aside — Default choice. Works for any read-heavy workload where occasional staleness is acceptable. The cache only grows with actually-requested data, saving memory.
  • Write-Through — Choose when reads vastly outnumber writes AND you need guaranteed freshness on reads (pricing, inventory, permissions). Accept the write-latency penalty.
  • TTL-only (no invalidation) — Use for reference data that changes on a predictable schedule: feature flags refreshed hourly, currency rates refreshed every 15 minutes. Simple and low-maintenance.
  • Hybrid (Cache-Aside reads + Write-Through writes) — The most robust production approach. Write-Through keeps the cache warm for hot entities; Cache-Aside handles cold-start for rarely-read data.

Measure your cache hit rate (target: above 90% for hot paths) using Redis INFO statskeyspace_hits / keyspace_misses to validate your choice.

// Measuring cache hit rate from Node.js
async function getCacheStats() {
  const info = await redis.info('stats');
  const hits   = parseInt(info.match(/keyspace_hits:(\d+)/)[1]);
  const misses = parseInt(info.match(/keyspace_misses:(\d+)/)[1]);
  const total  = hits + misses;
  const hitRate = total > 0 ? ((hits / total) * 100).toFixed(2) : '0.00';

  return {
    hits,
    misses,
    hitRate: `${hitRate}%`,
    recommendation: parseFloat(hitRate) < 90
      ? 'Consider extending TTL or switching to Write-Through'
      : 'Cache performance is healthy',
  };
}

module.exports = { getCacheStats };
// Output example:
// { hits: 9823, misses: 177, hitRate: '98.23%', recommendation: 'Cache performance is healthy' }

Knowledge Check: Pattern Selection

Test your understanding of caching pattern trade-offs.

Lesson Recap: Caching Patterns and TTL Strategies

You have covered the core caching toolkit for Node.js backends with Redis. Here is what to take away:

  • Cache-Aside (Lazy Loading): Check cache → miss → query DB → populate cache → return. Resilient to Redis failures; risk of cache stampedes under high concurrency.
  • Write-Through: Every DB write also updates the cache. Guarantees freshness on reads; adds write latency. Best for high-read, low-write data where correctness matters.
  • TTL Strategies: Match expiration windows to data volatility — seconds for real-time data, hours for static config. Use sliding TTL (EXPIRE on read) for active-user sessions.
  • Stampede Prevention: Use a Redis-based distributed mutex (SET NX EX) to serialise concurrent misses for the same key.
  • Invalidation: Prefer surgical DEL on known keys; use SCAN + DEL (never KEYS) for pattern-based invalidation in production.
  • Measure first: Track keyspace_hits / keyspace_misses — a hit rate below 90% signals a misconfigured TTL or wrong pattern choice.

The hybrid approach — Cache-Aside reads with Write-Through writes — is the most battle-tested setup for production Node.js services.

Frequently asked questions

Is the “Cache-Aside, Write-Through, and TTL Strategies” lesson free?

Yes — the full text of “Cache-Aside, Write-Through, and TTL Strategies” is free to read here on the web, and the Node.js Backend Development Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Node.js Backend Development Bootcamp course, upgrade to CoddyKit PRO.

What will I learn in “Cache-Aside, Write-Through, and TTL Strategies”?

Choose the right caching pattern and expiration policy to balance freshness against database load. You practise Node.js Backend Development Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Node.js Backend Development Bootcamp?

No prior experience is required. Node.js Backend Development Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Cache-Aside, Write-Through, and TTL Strategies” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Node.js Backend Development Bootcamp lesson?

Yes. Every Node.js Backend Development Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Cache-Aside, Write-Through, and TTL Strategies
  2. Distributed Locks and the Redlock Algorithm
  3. Pub/Sub, Streams, and Rate Limiting with Redis
  4. Preventing Cache Stampedes and Thundering Herds
← Back to Node.js Backend Development Bootcamp