# Caching Layers: CDN, Redis, and Memory

> The fastest request is the one your servers never see. How to layer CDN, Redis and in-memory caching without serving stale nonsense.

- Author: Syed Wajeeh Haider
- Published: Jun 30, 2026
- Series: Scaling to 1M Requests, part 3 of 10
- Category: Performance
- Tags: Caching, Redis, CDN, Performance
- Reading time: 3 min
- URL: https://syedwajeehhaider.com/caching-layers-cdn-redis

At a million requests, most of your traffic is asking the same questions over and over: the same product page, the same feed, the same config. Caching turns repeated work into a lookup. Done well, it is the single biggest multiplier on capacity you have. Done carelessly, it serves the wrong data to the wrong user. The trick is to cache in layers and be deliberate about each one.

- **<1 ms**: typical in-process cache lookup
- **~1 ms**: typical Redis round trip in-region
- **90%+**: hit ratio worth aiming for on hot reads

## Layer one: the CDN

A CDN such as CloudFront serves responses from edge locations close to users, so cached requests never reach your origin at all. Static assets are the obvious win, but public API responses and server-rendered pages can be cached too, as long as they are the same for everyone.

You control CDN behaviour with the `Cache-Control` header. `max-age` applies to browsers, `s-maxage` applies to shared caches like the CDN, and `stale-while-revalidate` lets the edge serve a slightly old copy instantly while it fetches a fresh one in the background.

*Public, CDN-cacheable API response*

```ts
app.get('/api/products/:id', async (req, res) => {
  const product = await getProduct(req.params.id);

  // Browsers: 60s. CDN: 5 min. Serve stale for 1 min while refreshing.
  res.set(
    'Cache-Control',
    'public, max-age=60, s-maxage=300, stale-while-revalidate=60',
  );
  res.json(product);
});
```

> **Never cache personal data at the edge:** Anything that depends on the logged-in user should be sent with `Cache-Control: private, no-store` or at least `private`. A misconfigured shared cache can show one user another user's data.

## Layer two: Redis with cache-aside

For data that is shared but too dynamic or too private for the CDN, a Redis cache in front of the database is the workhorse. The most common pattern is **cache-aside**: read from Redis, on a miss read from the database and write the result back with a TTL.

*Cache-aside with ioredis*

```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) return JSON.parse(hit) as T;

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

// Usage
const product = await cached('product:' + id, 300, () => db.product.findUnique({ where: { id } }));
```

## Choosing TTLs

A TTL is a statement about how stale the business can tolerate a value being. Prices in a checkout flow might tolerate seconds; a list of countries can live for a day. Start by asking the product question, then add a little **jitter** (a random few percent) to TTLs so that thousands of keys created together do not all expire in the same second.

## Stampede protection

A cache stampede happens when a hot key expires and hundreds of concurrent requests all miss at once, all hit the database, and all try to rebuild the same value. Two simple defences work well:

- **Request coalescing** inside each process: if a load for a key is already in progress, await the same promise instead of starting another.
- **A short Redis lock**: the first request takes a lock with `SET key NX EX`, rebuilds the value, and others briefly wait or serve the stale copy.

*In-process request coalescing*

```ts
const inflight = new Map<string, Promise<unknown>>();

export function coalesce<T>(key: string, load: () => Promise<T>): Promise<T> {
  const existing = inflight.get(key);
  if (existing) return existing as Promise<T>;

  const p = load().finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}

// cached(key, ttl, () => coalesce(key, loadFromDb))
```

## Invalidation, and what not to cache

The simplest invalidation strategy is a short TTL. When that is not enough, delete the key on write (`redis.del` after the database commit) or version the key, for example `product:42:v7`, so a write simply moves readers to a new key. For CDN content, prefer versioned asset URLs over mass invalidations.

Some things should not be cached at all: anything security-sensitive such as permissions checks, balances or inventory at the moment of purchase, and responses so cheap to compute that caching only adds a network hop. A small in-memory cache per instance, with a TTL of a few seconds, is often enough for configuration and feature flags.

## Key takeaways

- Cache in layers: CDN for public responses, Redis for shared data, memory for tiny hot values.
- Use `s-maxage` and `stale-while-revalidate` to let the edge absorb traffic safely.
- Pick TTLs from business tolerance, add jitter, and protect hot keys from stampedes.
- Never cache user-specific data in shared caches; invalidate on write or version your keys.

Next up, part 4: **Database Scaling: Indexes, Replicas, and Pooling**, for the requests that still have to reach the database.
