HIT
TTL
DB
Cache Strategy
aside • write-through • ttl

Caching strategies to reduce database load

Practical caching strategies for reducing database and API load: cache-aside, write-through, write-behind, TTLs, and invalidation.

13 min read16/06/2026

Symptom: the database becomes the bottleneck

The system is slow. You open monitoring and see:

  • query count growing linearly with user count
  • the same query running hundreds of times per second with unchanged results
  • database CPU sitting at 80–90%
  • API response time climbing through peak hours

This is a sign that the system is pushing every request down to the database, including data that never changes.

Caching solves this by storing computed results at a faster layer (memory), so the next request skips the expensive query entirely.

What caching actually solves

Cache isn't a cure-all. It only helps when:

  • Data changes infrequently, or can tolerate a few seconds to a few minutes of staleness
  • The same data is read many times by many users or many requests
  • Computation cost is high: complex queries, large joins, heavy aggregates
  • External APIs with rate limits or high latency

Cache doesn't help when data must be real-time: account balances, in-progress order status, per-transaction inventory.


Cache-Aside (Lazy Loading)

The most common strategy. The application manages the cache directly:

1. Read from cache
2. If found → return immediately (cache hit)
3. If not found → query database → write to cache → return
async function getUserById(id: string) {
  const cacheKey = `user:${id}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await db.users.findUnique({ where: { id } });
  if (user) {
    await redis.set(cacheKey, JSON.stringify(user), 'EX', 300); // 5 min TTL
  }

  return user;
}

Advantages

  • Only caches data that is actually read — no memory waste
  • A cache miss doesn't crash the system, just slows down that request
  • Easy to implement, no changes needed to the database layer

Disadvantages

  • First cache miss is always slow — must hit the DB
  • Thundering herd: when cache expires, many concurrent requests all miss at once and hammer the DB simultaneously
  • Data in cache can go stale if the DB is updated outside the main code path

Handling the thundering herd

Use a lock when populating cache so only one request queries the DB:

async function getUserById(id: string) {
  const cacheKey = `user:${id}`;
  const lockKey = `lock:${cacheKey}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const lock = await redis.set(lockKey, '1', 'NX', 'EX', 5);
  if (!lock) {
    // Another request is populating — wait and retry
    await sleep(50);
    return getUserById(id);
  }

  try {
    const user = await db.users.findUnique({ where: { id } });
    if (user) await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);
    return user;
  } finally {
    await redis.del(lockKey);
  }
}

Write-Through

When writing to the database, simultaneously update the cache:

Write request → write DB → write cache → return OK
async function updateUser(id: string, data: UpdateUserDto) {
  const user = await db.users.update({ where: { id }, data });

  const cacheKey = `user:${id}`;
  await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);

  return user;
}

Advantages

  • Cache always has fresh data after every write — no stale data
  • Reads immediately after a write always hit the cache

Disadvantages

  • Write latency increases since both places must be written
  • Cache holds data for every record, even rarely-read ones — memory overhead
  • If DB write succeeds but cache write fails, you have an inconsistency to handle

When to use

Good fit for data that is both read-heavy and frequently updated, where you need the cache to never go stale. Examples: user profiles, system settings, frequently-changing config.


Write-Behind (Write-Back)

Write to cache first, then flush to the database asynchronously:

Write request → write cache → return OK immediately
                            → background job writes DB

Advantages

  • Extremely low write latency — only costs the time to write to cache
  • Suitable for high write-throughput systems: view counts, like counts, analytics events

Disadvantages

  • Risk of data loss: if the cache server dies before flushing, that data is gone
  • More complex to implement correctly
  • Harder to debug when DB and cache are out of sync

Example: post view counter

async function incrementPostView(postId: string) {
  await redis.incr(`post:${postId}:views`);
}

// Background job runs every 5 minutes — flushes to DB
async function flushViewCountsToDb() {
  const keys = await redis.keys('post:*:views');
  for (const key of keys) {
    const postId = key.split(':')[1];
    const views = parseInt(await redis.get(key) ?? '0');
    await db.posts.update({
      where: { id: postId },
      data: { views: { increment: views } },
    });
    await redis.del(key);
  }
}

Read-Through

The cache automatically fetches from the DB on a miss, rather than leaving that to the application. Usually implemented via a library or caching layer (like Cacheable in NestJS, or cache providers in some ORMs).

@Injectable()
export class UserService {
  @Cacheable('user', { ttl: 300 })
  async getUserById(id: string) {
    return this.db.users.findUnique({ where: { id } });
  }
}

Logically identical to Cache-Aside, but the application code doesn't need to manually handle cache hit/miss logic — the library handles it.


Cache Invalidation — the hardest part

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

Three ways to invalidate cache:

1. TTL-based (auto-expire)

Simplest. Data automatically expires after a fixed time.

await redis.set(key, value, 'EX', 300); // expires in 5 minutes

Good when: data can be stale for a few minutes without serious impact (product catalog, articles, infrequently-changed config).

Not good when: data needs immediate consistency.

2. Event-based invalidation

Delete or update relevant cache keys when a write happens:

async function updateProductPrice(productId: string, price: number) {
  await db.products.update({ where: { id: productId }, data: { price } });

  // Delete all affected cache keys
  await redis.del(`product:${productId}`);
  await redis.del(`category:${product.categoryId}:products`); // list cache is also stale
  await redis.del(`homepage:featured`);
}

The hard part: you have to know every cache key affected by a given write. As the system grows, this list gets long and it's easy to miss one.

3. Tag-based invalidation

Attach tags to cache keys, invalidate by tag:

await cacheWithTag(`product:${id}`, data, ['products', `category:${categoryId}`]);

// When a category changes, invalidate all keys with that tag
await invalidateByTag(`category:${categoryId}`);

More complex to implement yourself, but some cache libraries support it natively (Next.js has revalidateTag, some Redis wrappers support tags).


Choosing TTL values

No single number works for everything. Rough guidelines:

Data type Suggested TTL Reason
Static config, feature flags 5–15 min Rarely changes, short staleness OK
Product catalog, articles 1–5 min Infrequent updates, expensive queries
Search results, lists 30–60 sec Short staleness OK, heavy queries
User profiles 5 min + invalidate on write Rare changes but must be correct when changed
Sessions, auth tokens Match session lifetime Must align with auth logic
Real-time data (prices, inventory) No cache or TTL ≤5s Staleness affects business logic

Common mistakes

Caching entire API responses

Caching responses that include user-specific data causes user A to see user B's data. Only cache data that isn't user-specific, or include the user ID in the cache key.

Cache key isn't unique enough

// Wrong: two different queries share the same key
await redis.set('products', JSON.stringify(products));

// Right: key must reflect all relevant parameters
const key = `products:category:${categoryId}:page:${page}:limit:${limit}`;

Not handling cache stampede on restart

When deploying or restarting the cache server, all keys expire at once and all traffic hits the DB simultaneously. Solutions: use a stale-while-revalidate pattern, or warm the cache before traffic arrives.

Storing oversized objects

Redis works well for small to medium objects. Multi-MB objects belong in object storage — cache should only hold metadata or signed URLs.

Not monitoring cache hit rate

A hit rate below 80% usually signals a TTL that's too short, wrong cache keys, or data that simply isn't a good candidate for caching.


Checklist

  • Is this data actually read many times with the same parameters?
  • How long can this data be stale?
  • Is the cache key unique enough to avoid serving wrong data?
  • When writing, have you invalidated all affected cache keys?
  • Is there a metric tracking hit rate?
  • Have you considered thundering herd when cache mass-expires?
  • Is the object being stored too large for cache?