Performance

Caching Strategies

Caching is how systems stay fast under load. The patterns are simple; the hard part is knowing what to keep, for how long, and when to throw it away.

intermediate15 min readUpdated Sep 16, 2026
cache-aside.ts
ts
// cache-aside.ts
export async function getProduct(id: string): Promise<Product> {
  const key = `product:${id}`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as Product;

  const product = await db.product.findUniqueOrThrow({ where: { id } });
  await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}
Primary goal
Lower latency and load
Default pattern
Cache-aside
Typical TTL
60s to 1h
Common store
Redis or in-process
Hard part
Invalidation
Edge layer
CDN plus HTTP headers

Why it matters

Why caching changes the whole system

Latency you can feel

A cache hit returns in well under a millisecond, while the database round-trip it replaces costs tens of milliseconds. On a page that reads twenty things, that difference is the whole experience.

Load the database never sees

Serving most reads from cache can cut database queries by an order of magnitude, which buys headroom without moving to a larger instance.

One idea, many layers

The same reasoning applies in the browser, at the CDN, inside the application and in the database, each with its own freshness and invalidation story.

The big picture

The three ideas behind every cache

Store a copy closer to the reader, serve it until it goes stale, and decide deliberately how it leaves.

Placement

Locate

Put the copy as close to the reader as correctness allows. Closer is faster, but harder to invalidate.

Storage

Serve

An in-memory store answers reads without touching disk, and a shared one keeps every instance seeing the same data.

Freshness

Expire

Every cached value needs a rule for how long it stays valid and how it is replaced when it no longer is.

At a glance

Where a cache can live

Browser cache

Cache-Control and ETag let the client reuse a response without asking the server.

CDN and edge

Shared caches at the edge absorb traffic for public, cacheable responses.

Application memory

An in-process Map or LRU cache is the fastest tier, but it lives and dies with each instance.

Redis or Memcached

A shared in-memory store sits beside the app and serves every instance consistently.

Database cache

Buffer pools, materialized views and prepared plans keep the source itself fast.

Invalidation

TTL, key versioning or explicit busts decide when a stale copy stops being served.

Flow

How a cache-aside read runs

The cache is consulted first, and only a miss reaches the database.

  1. 1

    Check the cache

    Build a deterministic key and ask Redis or the in-process store for it.

  2. 2

    Miss

    The key is absent, so the cache cannot answer. This is the moment that costs real work.

  3. 3

    Load from the source

    Query the database or recompute the expensive value exactly once.

  4. 4

    Write back with a TTL

    Store the result under the same key with an expiry, so the next read is a hit.

  5. 5

    Return and serve hits

    Later reads find the cached value and skip the source until the TTL lapses or an update invalidates the key.

The complete guide

Caching Strategies: Everything you need to know

Why cache at all

A cache is a copy of data kept somewhere cheaper to reach than the source. Every cache decision is really a bet: that the same value will be requested again before it changes, and that serving a slightly old copy is acceptable. When both hold, the payoff is enormous.

Three effects follow from a single hit:

  • Latency. A Redis lookup answers in well under a millisecond; the same row from PostgreSQL costs several milliseconds of network, planning and I/O. On a page that reads twenty things, that difference is the whole experience.
  • Load. If 95% of reads are served from cache, the database sees one query where it used to see twenty. You can survive a traffic spike, or run a smaller instance, without changing a line of query logic.
  • Cost. Fewer database reads mean smaller instances, fewer read replicas and less cross-region traffic. At scale, caching is one of the few optimisations that pays for itself in dollars.

The trade is complexity. A cache is a second copy of the truth, and every copy can disagree. Most of this guide is about keeping that disagreement small and temporary.

Where a cache can live

Caching is not one system but a stack of layers, each closer to the reader and each with a different invalidation story.

  • The browser. HTTP responses marked Cache-Control: max-age are reused without a request. This is free, private and largely under the client’s control.
  • The CDN or edge. A shared cache in front of your origin absorbs traffic for public responses. It is fast and global, but a mistake here is visible to everyone.
  • The application. An in-process cache (a Map, an LRU) is the fastest tier because it avoids the network entirely. It is also per-instance, so it must be small and disposable.
  • A shared store. Redis or Memcached sits beside the application and serves every instance from one consistent copy. This is where most application caching lives.
  • The database. Buffer pools, materialized views, prepared plans and query result caches keep the source fast on its own. They are the last line before disk.

A request can be answered at any of these layers. The closer the answer, the faster it is and the harder it is to invalidate, which is the central tension of the whole subject.

Cache hit rate: the metric that decides

The health of a cache is its hit rate: hits divided by hits plus misses. It is the only number that tells you whether the cache is doing anything.

hit rate = hits / (hits + misses)

The arithmetic is dramatic. At a 90% hit rate the database sees one in ten requests, a 10x reduction. At 50% it sees one in two, only a 2x reduction, and the cache has added a network hop to half the traffic. A cache with a low hit rate can be slower than no cache at all.

Measure it from the store itself. Redis reports keyspace_hits and keyspace_misses in INFO stats, and client libraries expose the same counters. Watch the hit rate after every change to a key, a TTL or a query; a small key change can quietly drop a 95% cache to 40%.

The corollary is that you must cache things that are actually repeated. A unique-per-request key has a 0% hit rate by construction and only wastes memory.

The four core patterns

Almost every cache implementation is one of four shapes. They differ in who fills the cache and when the cache is updated.

Cache-aside (lazy loading)

The application owns the logic. On a read it checks the cache, and on a miss it loads from the source and writes the result back. This is the default pattern because it is simple, works with any store, and only caches data that is actually requested.

async function getUser(id: string) {
  const key = `user:${id}`;

  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  const user = await db.user.findUniqueOrThrow({ where: { id } });
  await redis.set(key, JSON.stringify(user), "EX", 600);
  return user;
}

The cost is that the first reader always pays full price, and the cache can hold stale data until its TTL expires or a write removes it.

Read-through

Read-through moves the fallback into the cache layer itself: the application always asks the cache, and the cache is configured with a loader that runs on a miss. Some libraries and proxies implement this, so application code has no explicit miss path. The behaviour is the same as cache-aside; only the location of the logic changes.

Write-through

Write-through updates the cache and the source in the same operation. Reads are always hot and never see data older than the last successful write.

async function updateUser(id: string, patch: Partial<User>) {
  const user = await db.user.update({ where: { id }, data: patch });
  await redis.set(`user:${id}`, JSON.stringify(user), "EX", 600);
  return user;
}

It costs write latency and caches data that may never be read, but it keeps the cache consistent with the write path, which is exactly what user-facing updates need.

Write-behind (write-back)

Write-behind writes to the cache immediately and flushes to the source asynchronously. Writes are very fast and can be batched, which is valuable for counters and telemetry. The risk is severe: if the cache dies before the flush, the write is gone. Use it only where losing the last few writes is acceptable, and never for money or records of record.

TTLs and freshness

Every cached value should have a time to live. The TTL is a staleness budget: it is the maximum time a reader can see a value that is no longer true in the source. Choosing it is a product decision as much as a technical one.

  • Prices and inventory: seconds. A wrong price is a support ticket.
  • Profiles, feeds and listings: minutes. Small drift is invisible.
  • Reference data, feature flags and configuration: minutes to hours.
  • Static assets and rarely changing content: days, versioned by filename.

Add jitter to the TTL. If a thousand keys are written at the same moment with the same TTL, they expire together and produce a spike. Randomising by a few percent spreads the reloads out.

const ttl = 600 + Math.floor(Math.random() * 60);
await redis.set(key, JSON.stringify(value), "EX", ttl);

Even with explicit invalidation, keep a TTL as a safety net. A bug that forgets to delete a key should cost you minutes of staleness, not a permanent lie.

Invalidation: the hard part

There is a reason cache invalidation is the punchline of the oldest joke in computer science. The value is trivial to store and genuinely hard to remove at the right moment, from every layer, without races.

Three techniques cover almost every case.

Key versioning. Instead of deleting, change the key. Prefix keys with a version that you bump when the underlying data changes, so old entries become unreachable and expire on their own.

const version = (await redis.get(`user:${id}:v`)) ?? "1";
const key = `user:${id}:v${version}`;

This is race-free and works across instances, which is why it is the preferred approach for anything shared.

Explicit bust. Delete the key on write. It is the most obvious approach and the easiest to get wrong, because every write path must remember to do it.

await db.user.update({ where: { id }, data: patch });
await redis.del(`user:${id}`);

Event-driven invalidation. Publish a change event and let every instance and layer react. This is how a CDN purge or a multi-service cache is kept honest, and it scales to systems you do not own.

Whichever you choose, be consistent about ordering, and prefer deleting to overwriting on update. Deleting is idempotent; overwriting can resurrect a value that a concurrent writer already superseded.

Cache stampede and thundering herd

When a popular key expires, every request in flight misses at the same instant. If a thousand requests arrive per second and the key takes 200 ms to rebuild, hundreds of identical queries hit the database together. That is a cache stampede, also called the thundering herd, and it can take down a service that was healthy a moment earlier.

Four mitigations, in rough order of preference:

  • Stale-while-revalidate. Serve the stale value immediately and refresh it in the background. Readers never wait and the source sees one refresh. HTTP has a header for exactly this.
  • Locking or single-flight. Only the first caller recomputes; everyone else waits briefly or serves stale data. Redis SET key value NX EX is a simple distributed lock.
  • Jittered TTLs. Spread expiries so all keys do not die together.
  • Probabilistic early expiration. Each read has a small, increasing chance of refreshing before the key actually expires, so the refresh is spread across many requests.

The lock must always have an expiry, or a crashed worker leaves the key locked forever.

What to cache and what never to

Cache data that is expensive to produce, read far more often than it changes, tolerant of small staleness, and shared across many readers. Product listings, public profiles, configuration, rendered fragments and expensive aggregates are all good candidates.

Never cache:

  • Authorization decisions in a shared cache. A role change or logout must take effect immediately, and a CDN must never serve one user’s private response to another.
  • Per-user sensitive data under a shared key. Every user-specific value needs a key that includes the user, and a private cache directive if it ever reaches an edge.
  • Secrets and credentials. A cache is another place they can leak from.
  • Data you cannot invalidate. If a value has no natural key and no event, a cache will eventually serve something wrong with no way to fix it.

A useful test: if you cannot describe how a value leaves the cache, do not put it in.

HTTP caching at the edge

The cheapest cache is the one that never reaches your server. HTTP gives you precise control over it.

Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Vary: Accept-Encoding

max-age is how long the browser may reuse the response. s-maxage overrides it for shared caches such as a CDN. stale-while-revalidate lets the edge serve a stale copy while it fetches a fresh one in the background, which removes stampedes for public content. private and no-store keep sensitive responses out of shared caches entirely.

An ETag enables conditional requests. The client sends If-None-Match, and if the tag still matches you answer 304 Not Modified with no body, saving bandwidth while guaranteeing freshness.

app.get("/posts", async (req, res) => {
  const body = JSON.stringify(await listPosts());
  const tag = `"${createHash("sha256").update(body).digest("hex")}"`;

  res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
  res.set("ETag", tag);

  if (req.headers["if-none-match"] === tag) return res.status(304).end();
  res.type("application/json").send(body);
});

Vary is easy to forget and important: if a response depends on Accept-Encoding or Accept-Language, say so, or a cache will hand the wrong variant to the wrong client.

In-memory vs Redis vs CDN

The three shared layers are not competitors; they are a hierarchy.

An in-process cache is the fastest possible lookup because it never crosses the network. It is ideal for small, hot, read-mostly data such as configuration or a permission map. Its limits are that each instance has its own copy, so memory is multiplied and values can diverge, and it disappears on deploy.

Redis is the shared application cache. One logical copy serves every instance, it survives restarts, and it supports TTLs, atomic operations and data structures beyond simple strings. It costs a network round-trip, usually well under a millisecond on the same network.

A CDN is the outermost layer. It caches public responses close to users around the world and can absorb enormous traffic before it reaches you. It only works for responses that are identical for many users, and purging it is a deliberate, sometimes slow, operation.

A common production shape is a tiny in-process cache in front of Redis for the hottest keys, with a CDN in front of the whole API for public GETs.

Negative caching

Caches are usually described as storing values, but storing the absence of a value is just as useful. If a query for a missing record is expensive and repeated, cache the miss.

const hit = await redis.get(key);
if (hit === "__miss__") return null;

const user = await db.user.findUnique({ where: { id } });
if (!user) {
  await redis.set(key, "__miss__", "EX", 60);
  return null;
}

Keep negative TTLs short, because a record that did not exist a minute ago may exist now. The pattern defends against cache penetration, where a flood of requests for nonexistent keys bypasses the cache and hits the database every time. Guard against an attacker generating endless unique missing keys by validating input and rate limiting before the cache.

Consistency and the two hard things

A cache makes a system eventually consistent: for a short window, different readers can see different values. That is usually fine, but some situations are not.

  • Read-your-writes. A user who just updated their profile expects to see the change. Invalidate on write, or read from the source for a short period after a write by the same user.
  • Replication lag. If reads go to a replica, a cache filled from that replica can lag the primary. Invalidate from the primary’s write path.
  • Cross-region caches. A purge in one region does not instantly reach another. Version keys or accept the propagation delay.

The honest framing is that caching trades consistency for speed, and the only way to do it safely is to decide explicitly which staleness is acceptable and to build invalidation into the write path rather than bolting it on.

Cache keys are an API

A cache key looks like an implementation detail and behaves like a public interface. It is what every reader and writer must agree on, and it is visible in Redis, in slow logs and on dashboards. Design keys deliberately.

Three rules keep them sane:

  • Namespace by version and environment. v1:user:42 lets you change the shape later and keeps staging from sharing keys with production.
  • Be deterministic. The same logical lookup must always produce the same string. Normalise case, trim input and never include a value that changes per call.
  • Never put secrets or personal data in a key. Keys are logged, exported and shown to operators.
export const cacheKeys = {
  user: (id: string) => `v1:user:${id}`,
  userPosts: (id: string, cursor: string) => `v1:user:${id}:posts:${cursor}`,
  productBySku: (sku: string) => `v1:product:sku:${sku.trim().toLowerCase()}`,
};

Centralising key construction in one module is what makes invalidation possible. When a write needs to drop a key, it calls the same function the read used; when the shape changes, there is one place to bump the version.

Eviction policies

A cache that is allowed to grow without bound is a memory leak with extra steps. Both Redis and in-process caches need a ceiling and a policy for what to remove when it is reached.

Redis exposes maxmemory and maxmemory-policy. The common choice for a pure cache is allkeys-lru, which evicts the least recently used key, or allkeys-lfu, which favours frequently used ones when access patterns are skewed.

redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru

Never use noeviction for a cache: once memory is full, writes fail and your application starts throwing errors instead of simply missing. For in-process caches, use a bounded LRU library rather than a plain Map, and set both a maximum entry count and a maximum size.

Evictions are not failures, but a rising eviction count is a signal. It means the working set no longer fits and the hit rate is about to fall. Either give the cache more memory or cache less.

Cache warming

A cache is coldest the moment it is most dangerous: right after a deploy, a restart or a scale-out. Every instance starts empty, traffic arrives at full volume, and the source sees the full load until the cache fills. This is a stampede caused by your own deployment.

Two approaches work. Lazy warming accepts the cold window and relies on locking and stale-while-revalidate to survive it. It is simple and needs no extra machinery. Proactive warming runs a job that loads the known hot keys before the new version takes traffic.

async function warmCache() {
  const popular = await db.product.findMany({
    orderBy: { views: "desc" },
    take: 500,
    select: { id: true },
  });

  for (const { id } of popular) {
    await cached(`product:${id}`, 300, () =>
      db.product.findUniqueOrThrow({ where: { id } }),
    );
  }
}

Warm only what you know is hot. Warming everything is just a slow, expensive copy of the database that will mostly be evicted unused.

Observability for caches

A cache you do not measure is a cache you cannot trust. Four numbers tell the story.

  • Hit rate — are reads actually being served from the cache?
  • Latency — is a hit meaningfully cheaper than the source, including the network hop?
  • Evictions — is the working set outgrowing the memory?
  • Errors and timeouts — is the cache becoming a source of failures?
redis-cli INFO stats | grep -E 'keyspace_(hits|misses)|evicted_keys'
redis-cli INFO memory | grep used_memory_human

Emit a counter for hits and misses from the application as well, tagged by cache name. That is what lets you correlate a hit-rate drop with a deploy, and it is the first thing to check when database load rises for no obvious reason.

Graceful degradation when the cache is down

If losing the cache takes down the API, the cache has become a single point of failure for a system whose whole purpose is to be optional. The source of truth still exists; the application should be able to reach it.

Wrap every cache operation so a failure becomes a miss rather than an error, keep timeouts short, and consider a circuit breaker that stops calling a failing cache for a cooldown period.

async function safeGet(key: string) {
  try {
    return await redis.get(key);
  } catch (err) {
    logger.warn({ err, key }, "cache_read_failed");
    return null;
  }
}

The same applies to writes to the cache: a failed set should never fail the request. The one exception is write-behind, where the cache is part of the write path; that is another reason to reserve it for data you can afford to lose.

Caching expensive queries and aggregates

Not everything worth caching is a single row. Expensive joins, dashboard aggregates and search results are often the best candidates, because they are slow to compute and change slowly.

A materialized view is a cache that lives in the database: it stores the result of a query and is refreshed on a schedule or on demand. It gives you the freshness of a batch job and the read speed of a table.

CREATE MATERIALIZED VIEW daily_revenue AS
SELECT date_trunc('day', created_at) AS day,
       sum(total_cents)             AS revenue_cents
FROM orders
WHERE status = 'paid'
GROUP BY 1;

REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;

For results that are too dynamic for a view, cache the serialized response in Redis under a key that includes every input that affects it — filters, sort order and page. Two requests for different pages are different values, so they must be different keys.

Testing a cache

Cache bugs are the kind that pass tests and fail in production, so test the behaviours that matter explicitly: the miss path, the hit path, invalidation and failure.

test("loads once and serves the second read from cache", async () => {
  let calls = 0;
  const load = async () => {
    calls += 1;
    return { id: "1" };
  };

  await cached("user:1", 60, load);
  await cached("user:1", 60, load);

  expect(calls).toBe(1);
});

test("falls back to the source when the cache is unavailable", async () => {
  redis.get = async () => {
    throw new Error("connection_refused");
  };

  await expect(cached("user:1", 60, load)).resolves.toEqual({ id: "1" });
});

Use a real Redis in a container for integration tests, or an in-memory fake that implements get, set and del. Also test the negative cases: an update that should invalidate, a TTL that should expire, and a cache outage that should degrade instead of failing.

Best practices

  • Cache only after measuring; add a cache to a slow, repeated read, not by default.
  • Give every key a TTL, even when you also invalidate explicitly.
  • Use deterministic, namespaced keys such as user:42:profile and version them for shared data.
  • Prefer deleting or versioning keys over overwriting them on update.
  • Add jitter to TTLs and use stale-while-revalidate to survive stampedes.
  • Keep per-user and authorization data out of shared caches; mark responses private.
  • Treat the cache as optional in code so an outage degrades instead of failing.
  • Watch the hit rate and eviction count, not just latency.
  • Cache misses as well as hits when lookups of absent data are expensive.

Common mistakes

  • Caching without a TTL and relying on a manual purge that never comes.
  • Sharing one key across users and leaking one person’s data to another.
  • Invalidating in some write paths but not others.
  • Setting the same TTL on every key so they all expire together.
  • Caching an authorization decision that a role change should have revoked.
  • Using a cache as a database and losing writes when it restarts.
  • Forgetting Vary and serving the wrong content encoding from a CDN.
  • Measuring nothing, so a key change silently drops the hit rate.
  • Caching something unique per request, which guarantees a 0% hit rate.

Where to go next

Caching is one tool for controlling load; its natural companions are the others that bound work. Redis goes deep on the store most caches are built on, and Pagination is the request-level version of the same idea: never do more work than you must. If the database behind the cache is the bottleneck, Connection Pooling reduces the cost of each connection, and PostgreSQL covers the source of truth you are protecting.

In practice

Four caching jobs you will write first

The helper, the write path, stampede protection and the HTTP edge.

lib/cache.ts
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

export async function cached<T>(
  key: string,
  ttlSeconds: number,
  load: () => Promise<T>,
): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit) as T;

  const value = await load();
  await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
  return value;
}

// const product = await cached(`product:${id}`, 300, () =>
//   db.product.findUniqueOrThrow({ where: { id } }),
// );

Cache-aside vs write-through

Cache-aside only fills the cache on a read, so unused data never occupies memory. Write-through keeps the cache hot but pays on every write.

Cache-aside
// On read: miss, load, then populate.
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);

const value = await load();
await redis.set(key, JSON.stringify(value), "EX", 300);
return value;
Write-through
// On write: update the store and the cache together.
const value = await db.product.update({ where: { id }, data: patch });
await redis.set(key, JSON.stringify(value), "EX", 300);
return value;

TTL-only vs explicit invalidation

A TTL alone is simple and self-healing, but a reader can see data that is stale for the whole window. Explicit invalidation is fresher and easier to get wrong.

TTL only
// Stale for at most five minutes, then it heals itself.
await redis.set(key, JSON.stringify(value), "EX", 300);

// No update path to maintain, and a crashed
// writer cannot leave a permanently wrong value.
Explicit bust
// Fresher, but every write path must remember to do this.
await db.product.update({ where: { id }, data: patch });
await redis.del(`product:${id}`);

// Forget one call site and the cache lies forever.

Trade-offs

Is caching worth the complexity?

A cache is an extra system with its own failure modes. Add it when the measurements justify the moving parts.

Strengths

  • Dramatic latency wins

    Moving a read from a disk-backed database to memory can turn a 20 ms query into a 0.2 ms lookup, and that compounds across a page.

  • Protection under load

    A cache absorbs traffic spikes that would otherwise queue at the database, which is often the difference between degraded and down.

  • Real cost savings

    Fewer database reads mean smaller instances, fewer replicas and less egress, which shows up directly on the bill.

Trade-offs

  • Invalidation is genuinely hard

    A cache can serve data that no longer exists or hide data that does. Every write path becomes a place to make a mistake.

  • A second failure domain

    The cache can go down, fill up or return corrupt values. Code must fall back to the source and treat the cache as optional.

  • Stale reads surprise people

    Users expect to see their own writes immediately. Without careful invalidation, a cached profile or cart will look like a bug.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Caching Strategies?

Our interactive tutorial walks you through Caching Strategies step by step — with quizzes and real code you can run in the browser.