Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

Caching Strategies Explained: Cache-Aside vs Write-Through vs Write-Behind (With Redis Code)

A slow database query is one of the most common reasons a web app feels sluggish. Caching fixes it by keeping the answer in memory, so the next request skips the database. But the wrong caching strategy creates a new pro

A slow database query is one of the most common reasons a web app feels sluggish. Caching fixes it by keeping the answer in memory, so the next request skips the database. But the wrong caching strategy creates a new problem: users see stale prices, old profiles, or missing updates.

This guide explains the four caching strategies used in production: cache-aside, read-through, write-through, and write-behind. Each one comes with Node.js and Redis code, clear trade-offs, and the failure modes to plan for.

The Four Caching Strategies at a Glance

Cache-aside is the right default for most web apps. The other three solve specific problems it cannot.

Strategy How it works Best for Main risk
Cache-aside App checks the cache, falls back to the database, then fills the cache Read-heavy data such as products, articles, API responses Stale data until the TTL expires or the key is deleted
Read-through The cache layer loads from the database itself on a miss Same as cache-aside, with cleaner application code Needs a library or provider that supports it
Write-through Every write updates the database and the cache together Data that is read right after it is written, such as profiles and settings Slower writes, and memory spent on data nobody reads
Write-behind Writes go to the cache first and reach the database later in batches High-volume counters such as views, likes, and analytics events Data loss if the cache fails before the flush

1. Cache-Aside (Lazy Loading)

In cache-aside, your application code manages the cache. It checks the cache first, reads the database only on a miss, and stores the result for next time.

The examples below use the redis npm client. db stands in for your own data layer.

import { createClient } from "redis";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

async function getProduct(id) {
  const key = `product:${id}`;

  // 1. Try the cache first
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  // 2. Cache miss: read from the database
  const product = await db.products.findById(id);
  if (!product) return null;

  // 3. Store it for next time, with a TTL
  await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}

async function updateProduct(id, changes) {
  const product = await db.products.update(id, changes);
  await redis.del(`product:${id}`); // invalidate, don't update
  return product;
}

Notice that the write path deletes the key instead of updating it. If two requests update the same product at once, a delete can never leave an older value behind. The next read simply rebuilds the entry from the database.

Pros

  • Only data that is actually requested gets cached, so memory stays small.
  • If Redis goes down, the app still works. It just reads from the database.
  • It works with any database and any cache.

Cons

  • The first request for each key is slow, because it always misses.
  • Data can be stale until the TTL expires if you forget to invalidate on a write.
  • Caching logic gets repeated across your code unless you wrap it in a helper.

2. Read-Through

Read-through gives the same result as cache-aside, but the cache layer does the loading. Your code asks the cache for a key and never talks to the database directly.

You can get the same clean call sites with one small wrapper:

async function readThrough(key, ttl, loadFn) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const value = await loadFn();
  if (value != null) {
    await redis.set(key, JSON.stringify(value), { EX: ttl });
  }
  return value;
}

// Usage: one line per cached query
const product = await readThrough(`product:${id}`, 300, () =>
  db.products.findById(id)
);

The trade-offs match cache-aside. The gain is consistency: every cached query uses the same TTL rules, key format, and error handling.

3. Write-Through

Write-through updates the cache on every write, in the same operation as the database. Reads then almost always hit the cache, and they return fresh data.

async function saveProfile(userId, changes) {
  // Database first, so a failed write never reaches the cache
  const profile = await db.users.update(userId, changes);

  await redis.set(`user:${userId}`, JSON.stringify(profile), { EX: 3600 });
  return profile;
}

Use it when users read data straight after changing it. A profile page that shows the old name after a save is the classic bug this prevents.

It has two costs. Every write now does two operations, so writes get slower. You also cache records that may never be read, so always set a TTL to clear them out.

4. Write-Behind (Write-Back)

Write-behind sends each write to the cache only and saves it to the database later, in batches. The user gets an instant response, and the database sees one write instead of hundreds.

A page-view counter is a good fit:

async function recordView(postId) {
  // Fast path: touch the cache only
  await redis.incr(`post:${postId}:views`);
  await redis.sAdd("dirty:post-views", String(postId));
}

// Background worker: flush changed counters every 5 seconds
async function flushViews() {
  const ids = await redis.sPop("dirty:post-views", 500);

  for (const id of ids) {
    const views = await redis.get(`post:${id}:views`);
    await db.posts.update(id, { views: Number(views) });
  }
}

setInterval(flushViews, 5000);

A post that gets 1,000 views in five seconds now costs one database write, not 1,000.

The risk is data loss. Anything still waiting in the cache disappears if Redis crashes before the flush. Keep write-behind for data where losing a few seconds is acceptable, and never use it for orders or payments.

For heavier workloads, replace the simple set with a durable queue such as Redis Streams or Kafka. The worker can then retry a failed batch instead of dropping it.

Cache Invalidation and TTLs: Keeping Data Fresh

Every cached key needs two things: a TTL, and a plan for what happens when the source data changes. Skip either one and users will see stale data.

  • Always set a TTL. It is your safety net when an invalidation is missed. A key with no expiry can stay wrong forever.
  • Pick the TTL from the business rule. Ask how stale this data may be. A blog post can be minutes old. A stock count at checkout cannot.
  • Delete on write. When a record changes, delete its key in the same code path, as in the cache-aside example.
  • Use predictable key names. A format like product:42 or user:7:orders makes it easy to find what to delete.
  • Add jitter to TTLs. A small random offset stops thousands of keys from expiring in the same second.
// 300 to 359 seconds, so keys expire at different times
const ttl = 300 + Math.floor(Math.random() * 60);

Three Cache Failures to Plan For

Most caching bugs in production are one of three patterns. Each has a simple, well-known fix.

Cache stampede

A popular key expires, and hundreds of requests miss at the same moment. All of them run the same slow query, and the database stalls.

The fix is a short lock, so only one request rebuilds the value:

async function getWithLock(key, ttl, loadFn) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  // Only one request wins the lock
  const gotLock = await redis.set(`lock:${key}`, "1", { NX: true, EX: 10 });

  if (!gotLock) {
    // Someone else is rebuilding: wait briefly, then retry
    // (add a retry limit in production)
    await new Promise((resolve) => setTimeout(resolve, 100));
    return getWithLock(key, ttl, loadFn);
  }

  try {
    const value = await loadFn();
    await redis.set(key, JSON.stringify(value), { EX: ttl });
    return value;
  } finally {
    await redis.del(`lock:${key}`);
  }
}

Cache penetration

Requests keep asking for IDs that do not exist, such as /product/999999. Nothing is ever cached, so every request reaches the database. Bots and scrapers cause this often.

The fix is to cache the "not found" result for a short time:

if (!product) {
  await redis.set(key, "null", { EX: 30 }); // remember the miss
  return null;
}

Cache avalanche

A large share of keys expire together, or the cache restarts empty. The database suddenly takes the full traffic load.

TTL jitter, shown in the previous section, solves the first case. For the second, warm the most important keys at startup before sending traffic to the app.

How to Choose the Right Caching Strategy

Start with cache-aside and a TTL, then add another strategy only where a specific problem appears.

  1. Mostly reads, and slightly stale data is fine? Use cache-aside. This covers product pages, articles, search results, and most API responses.
  2. Users read data right after changing it? Add write-through for those records, such as profiles, settings, and carts.
  3. Thousands of small writes per minute? Use write-behind, but only where losing a few seconds of data is acceptable.
  4. Many cached queries across a large codebase? Wrap them in a read-through helper so every query follows the same rules.

Some things should not be cached at all:

  • Queries that already run in a few milliseconds. The cache adds complexity without a visible gain.
  • Values that must be exact at the moment of use, such as payment status or stock at checkout.
  • Data that is unique to each request and rarely repeated. The hit rate will be too low to matter.

Measure before and after. Track your cache hit rate and your slowest queries, and cache the few that cause most of the load.

Frequently Asked Questions

Which caching strategy is best?

Cache-aside is the best default for most web applications. It is simple, it survives a cache outage, and it only stores data that users actually request.

What is the difference between cache-aside and read-through?

The difference is who loads the data on a miss. In cache-aside, your application code queries the database and fills the cache. In read-through, the cache layer or a helper does it for you.

What is the difference between write-through and write-behind?

Write-through saves to the database immediately, as part of the same request. Write-behind saves to the cache first and updates the database later in batches. Write-through is safer, and write-behind is faster.

What TTL should I use for cached data?

There is no universal number. Start from how stale the data is allowed to be, then test. Many teams use minutes for content pages and hours for settings that rarely change.

Is Redis the only option for caching?

No. Memcached and Valkey work with the same patterns. A small in-process LRU cache is enough for a single server, and a CDN is the right cache for full HTTP responses.

Wrapping Up

Caching is one of the cheapest ways to make a web app faster, as long as the strategy fits the data. Use cache-aside by default, write-through where freshness matters, and write-behind only for high-volume writes you can afford to lose. Then set TTLs, add jitter, and protect hot keys from a stampede.

At Prism Infoways, we build and scale web applications for businesses worldwide. If your app is slowing down as traffic grows, our web development and cloud computing teams can help you find the bottleneck and fix it. You can reach us here.

Which caching strategy do you use in production, and what problems did it cause? Share your experience in the comments.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.