ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • API Documentation
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeCaching Strategies Every Backend Developer Must Know

Caching Strategies Every Backend Developer Must Know

A practical, code-first guide to cache-aside, read-through, write-through, write-behind, and refresh-ahead caching, with Redis examples, eviction policies, and invalidation strategies.

Pradeep Kumar
Pradeep KumarSenior Developer
August 19, 2026
12 min read
Caching Strategies Every Backend Developer Must Know
#backend#Caching#Redis#performance#System Design
👍1

There is a reason Phil Karlton's 1990s quip — that the two hardest problems in computer science are cache invalidation and naming things — still surfaces in every engineering conversation. Caching is simple in theory and genuinely difficult in practice. Get it right and your API responds in single-digit milliseconds under load. Get it wrong and you ship corrupted data, stale reads, or a cache stampede that takes down your database at peak traffic.

This guide cuts through the noise. You will learn the five core caching patterns, when to reach for each one, how eviction policies work, how to approach the invalidation problem, and what not to cache. Every section includes working code you can apply today.


Why Caching Matters: The Numbers

The performance gap between a database read and a cache read is not incremental — it is categorical. AWS benchmarks on RDS for MySQL 8.0 paired with ElastiCache show average read latency dropping from 14 ms to 0.51 ms — a 27x improvement — with RDS CPU utilization falling from 78% to 32% and throughput increasing 34% simultaneously. A single Redis instance, per Redis's own benchmark documentation, can sustain over 1.8 million GET operations per second with pipelining enabled. The database cannot compete with RAM on raw throughput.

The impact at scale is equally stark. Facebook's Memcached deployment, documented at USENIX NSDI '13, handles billions of requests per second across trillions of cached items — an architecture where the cache, not the database, is the primary read path for the world's largest social network. AWS benchmarks further show caching RDS workloads can reduce infrastructure costs by up to 55%, because fewer read replicas are needed.

The relationship between hit rate and database load is direct: at an 80% cache hit rate, 80% of your database reads are eliminated. The chart below shows how this scales:

The point is not that caching always helps — the sections below explain when it actively hurts. It is that ignoring it, at any meaningful scale, almost always leaves significant performance and cost on the table.


Core Terminology

Get these four concepts locked in before the patterns.

Cache hit / cache miss — A hit means the requested key exists in cache and is returned directly. A miss means it does not; the application must fetch from the source of truth (usually the database).

TTL (Time to Live) — How long a cached entry lives before it expires automatically. Too short and you get high miss rates; too long and you risk serving stale data. The right TTL is almost always workload-specific and discovered through measurement, not intuition.

Eviction policy — What happens when the cache runs out of memory. The cache must remove something to make room. Your policy choice determines what gets removed (covered in its own section below).

Cache stampede (thundering herd) — What happens when many requests simultaneously miss a key that just expired, all racing to repopulate it from the database at once. This is one of the most common ways caching makes an outage worse rather than better.


Quick Decision Framework

Before the deep dives, here is the pattern-selection map. Jump to whichever section applies:

Is data read frequently and written infrequently?
  └─ Yes → Cache-Aside (default) or Read-Through

Is data consistency critical on every write?
  └─ Yes → Write-Through
  └─ No, and writes are very frequent → Write-Behind (with durable queue)

Are hot keys causing stampedes on expiry?
  └─ Yes → Refresh-Ahead

Can you tolerate eventual consistency (minutes)?
  └─ Yes → TTL-based invalidation is sufficient

Must every read see the latest write immediately?
  └─ Yes → Do not cache this data

The Five Core Caching Patterns

1. Cache-Aside (Lazy Loading)

The most common pattern. The application owns all cache interaction logic. On a read, it checks the cache first. On a miss, it queries the database, stores the result in cache, and returns it to the caller.

Read request
     │
     ▼
  Cache hit? ──Yes──▶ Return cached value
     │
     No
     │
     ▼
  Query database
     │
     ▼
  Store in cache (with TTL)
     │
     ▼
  Return value

Node.js / Redis example:

import { createClient } from 'redis';

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

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

  // 1. Check cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached); // cache hit
  }

  // 2. Miss — fetch from database
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  if (!user) return null;

  // 3. Populate cache with a 10-minute TTL
  await redis.setEx(cacheKey, 600, JSON.stringify(user));

  return user;
}

Python equivalent:

import json
import os
import redis

r = redis.Redis.from_url(os.getenv("REDIS_URL"))

def get_user_by_id(user_id: int):
    cache_key = f"user:{user_id}"

    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    user = db.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
    if not user:
        return None

    r.setex(cache_key, 600, json.dumps(dict(user)))
    return user

When to use it: Read-heavy workloads where data changes infrequently. The cache only populates on demand, so you never pre-warm stale entries. It is resilient to cache failures — if Redis goes down, the application still works (slower, but correctly).

Trade-off: The first request after a cache miss (or expiry) always hits the database. Under high concurrency this becomes the stampede problem. Add a distributed lock or use the refresh-ahead pattern to mitigate.


2. Read-Through

Similar to cache-aside, but the cache itself fetches from the database on a miss, rather than the application. The application only ever talks to the cache.

// The cache client is configured with a loader function
const cache = new CacheClient({
  loader: async (key) => {
    const userId = key.split(':')[1];
    return db.query('SELECT * FROM users WHERE id = $1', [userId]);
  },
  ttl: 600,
});

// Application code is now clean:
async function getUserById(userId) {
  return cache.get(`user:${userId}`); // miss handled internally
}

When to use it: When you want to keep cache interaction logic in one place and out of business code. Works well with managed caching services that support loader callbacks (e.g., Momento, AWS ElastiCache with data tiering).

Trade-off: Slightly less flexible than cache-aside — you cannot customize the miss flow per call site. The first-request miss penalty is identical.


3. Write-Through

Every write goes to the cache and the database simultaneously, synchronously, before returning to the caller.

async function updateUser(userId, data) {
  const cacheKey = `user:${userId}`;

  // Write to database first (or transactionally with cache)
  const updated = await db.query(
    'UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING *',
    [data.name, data.email, userId]
  );

  // Immediately update cache
  await redis.setEx(cacheKey, 600, JSON.stringify(updated));

  return updated;
}

When to use it: When data consistency is critical and writes are not extremely frequent. The cache is always warm and always fresh. Read performance is excellent because entries exist immediately after a write — no cold-start miss.

Trade-off: Every write requires two round-trips (database + cache) before returning. For write-heavy workloads this compounds at scale. You also cache data that might never be read, wasting memory.


4. Write-Behind (Write-Back)

The application writes to the cache only, and a background process flushes dirty entries to the database asynchronously. The write returns immediately after the cache is updated.

// Simplified write-behind with a queue
async function updateUserAsync(userId, data) {
  const cacheKey = `user:${userId}`;

  // Write to cache immediately — caller gets fast response
  await redis.setEx(cacheKey, 600, JSON.stringify({ ...data, id: userId }));

  // Queue the database write for background processing
  await redis.lPush('db:write-queue', JSON.stringify({
    table: 'users',
    id: userId,
    data,
    timestamp: Date.now(),
  }));
}

// Background worker (runs separately)
async function processWriteQueue() {
  while (true) {
    const item = await redis.brPop('db:write-queue', 0); // blocking pop
    const { table, id, data } = JSON.parse(item.element);
    await db.query(`UPDATE ${table} SET name=$1, email=$2 WHERE id=$3`, [data.name, data.email, id]);
  }
}

When to use it: High write-throughput scenarios where sub-millisecond write latency is required and you can tolerate a small window of data loss. Session stores, analytics event pipelines, activity feeds.

Trade-off: Risk of data loss. If the cache node fails between the write and the flush, the database never gets updated. Always pair write-behind with Redis persistence (AOF or RDB) and durable queue infrastructure (e.g., Kafka, SQS) in production.


5. Refresh-Ahead (Proactive Refresh)

The cache predicts which entries are about to expire and refreshes them in the background before they go cold — so the caller never experiences a miss.

const REFRESH_THRESHOLD = 0.2; // refresh when 20% of TTL remains

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

  const [cached, ttlRemaining] = await Promise.all([
    redis.get(cacheKey),
    redis.ttl(cacheKey),
  ]);

  if (cached) {
    const fullTtl = 600; // your configured TTL
    const shouldRefresh = ttlRemaining < fullTtl * REFRESH_THRESHOLD;

    if (shouldRefresh) {
      // Trigger background refresh without blocking the caller
      refreshUserCache(userId).catch(console.error);
    }

    return JSON.parse(cached);
  }

  // Full miss — blocking refresh
  return refreshUserCache(userId);
}

async function refreshUserCache(userId) {
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  await redis.setEx(`user:${userId}`, 600, JSON.stringify(user));
  return user;
}

When to use it: Frequently accessed, expensive-to-compute data where cache miss latency is unacceptable. Home page aggregations, pricing data, leaderboards. Eliminates the stampede problem entirely for hot keys.

Trade-off: You may refresh data that was never requested after its refresh — wasted compute. Requires good observability to tune the threshold correctly.


Eviction Policies: What Gets Dropped When Memory Is Full

Policy

Full Name

How It Works

Best For

LRU

Least Recently Used

Evicts the entry accessed least recently

General-purpose; good default

LFU

Least Frequently Used

Evicts the entry accessed least often overall

Workloads with stable hot-key distribution

FIFO

First In First Out

Evicts oldest entry regardless of access

Simple queues; less common

TTL

Time-based

Evicts based on expiry time

All workloads; complements LRU/LFU

Random

—

Evicts a random entry

Rarely the right choice in production

Redis default is noeviction — it throws an error when memory is full rather than silently dropping data. Switch to allkeys-lru for most production workloads:

# In redis.conf or via CONFIG SET
maxmemory 2gb
maxmemory-policy allkeys-lru

For workloads with clear hot-key skew (e.g., celebrity social media posts), allkeys-lfu tends to outperform LRU because frequency is a better predictor of future access than recency. USENIX NSDI '18 research shows that hit rate increases logarithmically as a function of cache capacity — meaning the eviction policy becomes more important than raw memory size as you scale.


Cache Invalidation: The Hard Part

Phil Karlton was right. Cache invalidation is hard because the cache and the source of truth can diverge, and the application must detect and resolve that divergence without serving incorrect data or generating excessive database load.

The three practical strategies:

TTL-based expiry

Let entries expire automatically after a configured duration. Simple, predictable, eventually consistent.

await redis.setEx('product:123:price', 300, JSON.stringify(price)); // 5 min

Risk: Stale reads for up to the full TTL window. For pricing data, a 5-minute stale price may be acceptable. For account balances, it is not.

Event-driven invalidation

Invalidate (delete) a cache entry the moment the underlying data changes, rather than waiting for TTL expiry.

// In your update endpoint
async function updateProduct(productId, data) {
  await db.query('UPDATE products SET price = $1 WHERE id = $2', [data.price, productId]);

  // Immediately purge — next read will miss and repopulate
  await redis.del(`product:${productId}:price`);

  // Or pattern-delete all keys related to this product
  const keys = await redis.keys(`product:${productId}:*`);
  if (keys.length > 0) await redis.del(keys);
}

Risk: Delete-before-repopulate creates a brief window where all in-flight requests hit the database simultaneously. For high-traffic keys, use cache-aside with a distributed lock, or write-through instead.

Versioned keys

Append a version number or hash to the cache key. Updates increment the version, making the old key unreachable without requiring an explicit delete.

// Store the current version
await redis.set('product:123:version', 'v5');

// Cache key includes version
const version = await redis.get('product:123:version');
const cacheKey = `product:123:price:${version}`;

// On update: bump version (old key becomes unreachable)
await redis.incr('product:123:version');

This is the safest approach for multi-region or distributed caches where explicit delete propagation is unreliable. Old entries age out via TTL naturally.


Redis vs. Memcached: Which One?

This question comes up in every architecture discussion. The short answer for new projects: use Redis.

Redis

Memcached

Data types

Strings, hashes, lists, sets, sorted sets, streams

Strings only

Persistence

RDB snapshots + AOF log

None

Replication

Built-in primary/replica

External only

Pub/Sub

Yes

No

Lua scripting

Yes

No

Max value size

512 MB

1 MB

Multithreading

Multi-threaded I/O (Redis 6+)

Multi-threaded

Best for

Nearly all production use cases

Pure key/value, ultra-simple workloads

Memcached's one genuine advantage is slightly lower memory overhead for pure string storage at extreme scale. Unless you are operating at hundreds of terabytes of cache data, this rarely matters. Redis's richer data types, persistence options, and Lua scripting make it the clear default.

The benchmark comparison below is from real AWS infrastructure measurements — not synthetic lab tests:


What NOT to Cache

Caching is not universally beneficial. These are situations where it actively causes harm:

Highly volatile data. If a value changes more often than your TTL, you will serve stale data constantly while paying cache infrastructure overhead for essentially no hits. Financial ticker prices and live sports scores are common examples where a direct database or streaming feed is the right answer.

Unbounded key spaces. If your cache key includes a user-supplied parameter with high cardinality (e.g., search query strings), you will generate millions of cache keys that each get accessed once. This consumes memory, produces near-zero hit rates, and degrades eviction efficiency. Apply caching only to keys with stable, bounded spaces.

Data that is cheaper to recompute than to cache. Simple arithmetic or deterministic in-memory transformations have effectively zero cost. Routing them through a network call to Redis adds latency.

Data requiring real-time consistency. If the application contract requires that every read reflects every write with zero lag — shopping cart totals, bank balances, inventory counts during flash sales — a cache introduces a consistency lag that your users will experience as bugs.

Personally identifiable information you do not need to cache. Every cached PII record is an additional attack surface. If the TTL benefits do not justify the exposure, leave it out of the cache.


Preventing the Cache Stampede

When a hot key expires under heavy traffic, every concurrent request races to repopulate it. This is the stampede. Three proven defenses:

Probabilistic early expiry — Each process individually decides whether to refresh before expiry, with probability increasing as expiry approaches. Simple to implement with no coordination overhead.

function shouldEarlyRefresh(ttlRemaining, fullTtl, beta = 1) {
  // Higher beta = more aggressive early refresh
  return ttlRemaining <= fullTtl * beta * Math.random();
}

Distributed lock — Only one process is permitted to repopulate the key at a time. All others wait or return a stale value.

const lock = await redis.set(
  `lock:user:${userId}`,
  '1',
  { NX: true, EX: 10 } // only set if not exists, expire in 10s
);

if (lock) {
  // We won the lock — repopulate
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  await redis.setEx(`user:${userId}`, 600, JSON.stringify(user));
  await redis.del(`lock:user:${userId}`);
  return user;
} else {
  // Lost the lock — wait briefly and retry, or return a slightly stale value
  await sleep(50);
  return getUserById(userId);
}

Background refresh with stale-while-revalidate — Serve the stale value immediately while a single background process refreshes asynchronously. This is the refresh-ahead pattern applied reactively.


Monitoring Cache Health

A cache you cannot observe is a cache you cannot trust. Track these four metrics in production:

Metric

What It Tells You

Action Threshold

Hit rate

Fraction of reads served from cache

Investigate below 80%

Miss rate

Fraction of reads falling through to DB

Spikes indicate TTL or eviction issues

Eviction rate

Keys dropped due to memory pressure

Any eviction = consider memory increase

Latency (p99)

99th percentile cache round-trip

Above 1 ms warrants investigation

// Instrument your cache-aside reads
async function getUserById(userId) {
  const start = Date.now();
  const cacheKey = `user:${userId}`;
  const cached = await redis.get(cacheKey);

  if (cached) {
    metrics.increment('cache.hit', { key_type: 'user' });
    metrics.histogram('cache.latency_ms', Date.now() - start);
    return JSON.parse(cached);
  }

  metrics.increment('cache.miss', { key_type: 'user' });
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  await redis.setEx(cacheKey, 600, JSON.stringify(user));
  return user;
}

Summary

Caching is one of the highest-leverage tools in backend engineering. The five core patterns map cleanly to distinct use cases:

  • Cache-Aside — the safe, flexible default for read-heavy workloads

  • Read-Through — same as cache-aside but encapsulated inside the cache layer

  • Write-Through — strong consistency on writes, acceptable write latency

  • Write-Behind — maximum write throughput, requires durable queuing

  • Refresh-Ahead — eliminates miss latency for predictable hot keys

Master cache invalidation — TTL, event-driven delete, and versioned keys — before worrying about exotic patterns. Most real-world caching problems come down to stale data or stampedes, and both have well-understood solutions.

Start with Redis, instrument hit rate from day one, and set allkeys-lru as your eviction policy. Everything else you can tune iteratively as traffic grows.


FAQ

What is a good cache hit rate? In most production workloads, a hit rate above 80% is the practical target. At 80% hit rate, roughly 80% of your database reads are eliminated. Below 70%, TTL tuning and key design should be revisited. Note that hit rate increases logarithmically with cache capacity — so the eviction policy and key design matter more than raw memory at scale.

Should I use Redis or a CDN for caching? CDNs (Cloudflare, Fastly) cache at the network edge — ideal for static assets and API responses that are identical across users. Redis caches at the application layer — ideal for user-specific or frequently updated data. They are complementary, not alternatives.

How do I invalidate cache entries across multiple services in a microservices architecture? Use an event bus (Kafka, RabbitMQ, or Redis Pub/Sub). The service that owns the data publishes an invalidation event when it writes; all services that cache that data subscribe and delete their local entries. This is the event-driven invalidation pattern applied at scale.

Can caching make my application slower? Yes, in two scenarios: cache miss rate is so high that you are adding a network round-trip to every database call (net negative), or cache lookup latency exceeds the database query you were trying to skip (rare, but happens with misconfigured Redis in high-latency networks). Monitor before deploying caching and measure after.

What is a cold start / cold cache problem? When your cache is empty — after a deployment, a restart, or provisioning a new node — all requests miss until entries are populated. For critical keys, pre-warm the cache at startup by reading from the database and populating keys before traffic is admitted.

Pradeep Kumar

Pradeep Kumar

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Caching Strategies Every Backend Developer Must Know

Related Posts

BackFlow

BackFlow — a visual platform focused on API-First and API Design. The goal is to let designers, developers, and product teams visually design API flows before w...

Read article

Declarative Partial Updates: Out-of-Order HTML Streaming Without a Framework

Chrome 148 ships experimental support for filling HTML placeholders out of order and streaming markup into the DOM. Here's how the new template-for and streamHTML APIs work, and what they replace.

Read article

Handling Edge Cases in High-Frequency P2P Payouts: Lessons from Binance P2P and Safaricom M-Pesa Integration

Real-world engineering lessons from scaling automated crypto P2P payouts: solving sub-second race conditions, network drops, and double-spending vulnerabilities with hardware-level idempotency on Safaricom M-Pesa rails.

Read article

React's Activity Component: Hide UI Without Losing Its State

React 19.2's Activity component hides a subtree instead of unmounting it, so state, scroll position, and DOM survive the round trip. Here's how it behaves, what it does to your Effects, and where it costs you.

Read article

revalidateTag vs updateTag: Next.js Split Cache Invalidation in Two

Next.js 16 gave cache invalidation two different functions instead of one, and the split maps to a real distinction: content that can lag versus content the user just typed.

Read article