Skip to main content

⚡ API Response Caching Strategies

The fastest work is the work you never repeat. Caching stores a response so identical future requests are served in milliseconds instead of hitting your database again. This lesson covers what to cache, how HTTP already helps you, the main strategies, and the genuinely hard part: knowing when a cached answer has gone stale.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define cache hit, miss, TTL, and hit ratio and explain why caching pays off
  • Use HTTP Cache-Control directives and ETag validation correctly
  • Identify where caches live — client, CDN, gateway, application, database
  • Apply time-based, validation-based, and variation-based caching to the right situations
  • Choose an invalidation technique and avoid stale-data and cache-stampede pitfalls

Estimated Time: 45–60 minutes  •  Difficulty: Advanced

Hands-on: Add a Redis cache-aside layer to a slow endpoint and measure the gain.

In This Lesson

Why Cache at All?

Every uncached request repeats the same expensive work: query the database, run the computation, serialize the result. If a thousand users ask for the same product page in a minute, an uncached API does that work a thousand times. Caching does it once and hands out copies.

💡 A useful analogy: A busy coffee shop doesn't brew a fresh pot for every customer — it brews a batch and pours from it until it runs low. Caching is that batch: prepare the answer once, serve it quickly to everyone who asks, and only "brew again" when it goes stale.

The payoff is dramatic and compounding:

  • Latency drops from hundreds of milliseconds to single digits.
  • Server and database load fall, often by an order of magnitude.
  • Throughput and resilience rise — you serve traffic spikes on the same hardware, and can keep serving cached data even if the backend hiccups.
  • Cost shrinks because you provision for the cache-miss rate, not the total request rate.

⚠️ Caching's price

The tradeoff is freshness. A cache serves a snapshot, so for a window of time it may return data that no longer matches the source. The entire craft of caching is deciding how much staleness is acceptable, and reclaiming freshness when it isn't.

Caching Fundamentals

A handful of terms recur throughout every caching system:

📖 Key Terms

Cache hit: the requested item was found in the cache and served from it.

Cache miss: it wasn't there, so it had to be generated from the source and (usually) stored.

Cache key: the unique identifier under which a response is stored and looked up.

TTL (time-to-live): how long an entry stays valid before it expires.

Eviction: removing entries to reclaim memory (e.g. LRU — least recently used).

The headline metric is the cache hit ratio — the fraction of requests served from cache:

Cache hit ratio

hit ratio = cache hits / total requests

A 95% hit ratio means only one request in twenty reaches your backend. Small improvements here have outsized effects: going from 80% to 95% cuts backend load by three quarters. When a hit ratio is disappointing, the usual culprits are keys that are too specific (so nothing is reused) or TTLs that are too short (so entries expire before they're reused).

HTTP Caching & ETags

Before you build any custom cache, remember that HTTP has caching built in. By sending the right response headers, you let browsers and CDNs cache for you — for free.

Cache-Control

The Cache-Control header is the primary dial. Its directives compose:

DirectiveMeaning
max-age=3600Fresh for 3600 seconds in any cache
s-maxage=7200Like max-age, but only for shared caches (CDNs)
public / privateMay any cache store it, or only the end user's browser?
no-cacheMay store, but must revalidate before reuse
no-storeNever store this response anywhere
must-revalidateOnce stale, must check with the origin before serving
// Public, cacheable for a day, revalidate once stale
res.set('Cache-Control', 'public, max-age=86400, must-revalidate');

// Per-user data that must never sit in a shared cache
res.set('Cache-Control', 'private, no-store');

ETags and conditional requests

An ETag is a fingerprint of a response. The client stores it and sends it back in an If-None-Match header; if the resource is unchanged, the server replies 304 Not Modified with an empty body — saving bandwidth even when the client still had to check in.

sequenceDiagram participant C as Client participant S as Server C->>S: GET /api/products/42 S-->>C: 200 OK, ETag: "v7" Note over C: caches body + ETag C->>S: GET /api/products/42 (If-None-Match: "v7") alt unchanged S-->>C: 304 Not Modified (empty body) else changed S-->>C: 200 OK, ETag: "v8" (full body) end
import crypto from 'node:crypto';

app.get('/api/products/:id', async (req, res) => {
  const product = await getProduct(req.params.id);
  const etag = crypto.createHash('md5')
    .update(JSON.stringify(product))
    .digest('hex');

  // Client already has this exact version?
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }

  res.set('ETag', etag);
  res.set('Cache-Control', 'public, max-age=3600');
  res.json(product);
});

💡 Freshness vs. validation

max-age gives freshness: within the window, no server round-trip happens at all. ETag gives validation: a round-trip happens, but a 304 avoids re-sending the payload. The best APIs use both — a short max-age for zero-trip speed, and an ETag so revalidation after that is cheap.

Where Caches Live

A request can be answered by a cache at several points along its journey. The earlier a cache intercepts it, the faster and cheaper the response — but the less control you retain.

The chain of caches between client and database A request passes from browser cache to CDN to API gateway to application cache to database cache before reaching the database. Caching earlier is faster. Browser fastest CDN / Proxy edge API Gateway App (Redis) most control Database source of truth ← faster & cheaper to cache here | more control & freshness here →
Figure 1 — The request/response chain. A cache hit at the browser costs nothing; a hit at the app cache still saves the database. Each layer trades speed for control.
  • Client cache — eliminates the network entirely, but you can't purge it after deployment.
  • CDN / proxy — serves millions from the edge, close to users; great for public, non-personalized data.
  • API gateway — centralized, can cache authenticated responses with the right keys.
  • Application cache (Redis, Memcached) — the most flexible; you control exactly what and when.
  • Database cache — query/result caches inside the DB itself.

Core Caching Strategies

Which strategy fits depends on how your data changes.

Time-based (TTL)

The simplest: store the response with an expiry and serve it until the TTL runs out. Perfect for data that's expensive to compute and tolerant of being a little stale — a product catalog, a weather summary, a top-10 list.

Validation-based

Use ETags or Last-Modified so the client keeps its copy until the server confirms a change. Best when accuracy matters and data changes irregularly, and you want to save bandwidth on the (frequent) unchanged case.

Variation-based

Different inputs produce different responses, so the cache key must encode every input that affects the output — query parameters, language, region. Get this wrong and you either miss constantly (keys too fine) or serve the wrong content (keys too coarse).

// The key must capture EVERY input that changes the response
const { page = 1, limit = 10, sort = 'createdAt', category = 'all' } = req.query;
const cacheKey = `products:${category}:${sort}:${page}:${limit}`;

For HTTP-level variation, advertise it with the Vary header so shared caches key correctly:

res.set('Vary', 'Accept-Language');
res.set('Cache-Control', 'public, max-age=600');

⚠️ Never cache per-user data in a shared cache

If a response depends on who is asking (their profile, their cart), a public/shared cache can leak one user's data to another. Mark such responses private (or no-store), or key the application cache by user id. This is one of the most dangerous caching bugs.

The Cache-Aside Pattern

The workhorse of application-level caching is cache-aside (also called lazy loading): check the cache first; on a miss, load from the source and populate the cache for next time.

flowchart TD A[Request] --> B{In cache?} B -->|Hit| C[Return cached value] B -->|Miss| D[Query database] D --> E[Store in cache with TTL] E --> F[Return fresh value]
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

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

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

  // 2. Miss — go to the source of truth
  const product = await db.products.findById(id);

  // 3. Populate the cache for next time (1-hour TTL)
  await redis.set(key, JSON.stringify(product), 'EX', 3600);

  return product;
}

Cache-aside is popular because it's resilient (if the cache is down, requests just fall through to the database) and it only caches data that's actually requested. Its weaknesses are the first-request latency on every miss and the risk of a cache stampede.

💡 Cache stampede

When a popular key expires, hundreds of concurrent requests all miss at once and hammer the database together. Mitigate it with a short lock (only one request regenerates while others wait), by adding jitter to TTLs so keys don't expire in sync, or with the stale-while-revalidate pattern below.

Stale-while-revalidate serves the slightly-expired value immediately while refreshing it in the background — the user never waits for the regeneration:

// Serve stale within a grace window, refresh asynchronously
if (age > ttl && age < ttl + gracePeriod) {
  refreshInBackground(key);        // fire-and-forget
  res.set('X-Cache', 'stale');
  return res.json(staleValue);     // instant response
}

Cache Invalidation

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

Invalidation is deciding when a cached entry no longer reflects reality and removing it. There's no single right answer — you pick based on how tolerant your data is of staleness.

TechniqueHow it worksTrade-off
TTL expiryEntry auto-expires after a set timeSimple, but data is stale until expiry
Explicit (write-through)Delete/update the key when the source changesAccurate, but you must track every key
Versioned keysBump a version prefix to invalidate en masseEasy global reset; can't target one item
Event / pub-subBroadcast invalidation across instancesWorks when distributed; more infrastructure
Surrogate keys / tagsPurge all entries sharing a tag (CDN)Precise relational purges; needs CDN support

The most common combination is TTL as a safety net plus explicit invalidation on writes. When you update a record, delete its cache key so the next read repopulates it, and let the TTL catch anything you forget:

app.put('/api/products/:id', async (req, res) => {
  const product = await db.products.update(req.params.id, req.body);

  // Explicit invalidation: drop the stale entry immediately
  await redis.del(`product:${req.params.id}`);

  res.json(product);
});

✅ Match TTL to volatility

Set the TTL from how fast the data changes and how much staleness users can tolerate — seconds for a live scoreboard, minutes for a product list, hours for a country list. A single global TTL is almost always wrong.

Hands-on: Cache a Slow Endpoint

🏋️ Add cache-aside and measure the win

Objective: Wrap a deliberately slow endpoint with a Redis cache-aside layer, then confirm the second request is dramatically faster and correctly invalidated on write.

Starting point — an endpoint whose data source takes ~500 ms:

app.get('/api/report/:id', async (req, res) => {
  const report = await buildExpensiveReport(req.params.id); // ~500ms
  res.json(report);
});

Instructions:

  1. Add a cache-aside check keyed by report id with a 60-second TTL.
  2. Add an X-Cache header set to HIT or MISS and time both requests.
  3. Add a PUT route that rebuilds the report and invalidates the cached entry.
  4. Note the latency of the miss vs. the hit — that difference is your payoff.
💡 Hint

Reuse the getProduct cache-aside shape from Section 6: redis.get first, return on hit; otherwise build, redis.set with 'EX', 60, and return. For invalidation, call redis.del in the PUT handler.

✅ Sample solution
app.get('/api/report/:id', async (req, res) => {
  const key = `report:${req.params.id}`;

  const cached = await redis.get(key);
  if (cached) {
    res.set('X-Cache', 'HIT');           // ~2ms
    return res.json(JSON.parse(cached));
  }

  const report = await buildExpensiveReport(req.params.id); // ~500ms
  await redis.set(key, JSON.stringify(report), 'EX', 60);
  res.set('X-Cache', 'MISS');
  res.json(report);
});

app.put('/api/report/:id', async (req, res) => {
  const report = await rebuildReport(req.params.id, req.body);
  await redis.del(`report:${req.params.id}`); // invalidate
  res.json(report);
});

The first call is a MISS at ~500 ms; every call within the next 60 seconds is a HIT at a few milliseconds — a ~100x speedup and one database hit instead of many. The PUT ensures a stale report is never served after an update.

🎯 Quick Quiz

Question 1: What does a 304 Not Modified response indicate?

Question 2: In the cache-aside pattern, what happens on a cache miss?

Question 3: Why must a response that depends on the logged-in user be marked private (or not cached in a shared cache)?

Best Practices

✅ Do

  • Lean on HTTP caching (Cache-Control, ETag) before building a custom layer.
  • Set TTLs from each resource's real change frequency, not one global number.
  • Invalidate explicitly on writes, with TTL as a safety net.
  • Include every response-affecting input in the cache key (and set Vary).
  • Guard against stampedes with locks, TTL jitter, or stale-while-revalidate.
  • Monitor the hit ratio and tune keys and TTLs from real data.

⚠️ Don't

  • Don't cache per-user or sensitive data in a shared/public cache.
  • Don't cache non-idempotent responses (POST/PUT/DELETE results).
  • Don't let cache failures take down the request path — fall through to the source.
  • Don't pick TTLs so long that users routinely see stale data.

Summary & Quiz

🎉 Key Takeaways

  • Caching trades a little freshness for large gains in latency, load, and cost; the hit ratio measures success.
  • HTTP gives you caching for free via Cache-Control (freshness) and ETag (validation / 304s).
  • Caches live at many layers — client, CDN, gateway, app, database — trading speed for control.
  • Match the strategy to your data: time-based, validation-based, or variation-based.
  • Cache-aside is the go-to application pattern; guard it against stampedes.
  • Invalidation is hard — combine explicit purges on write with a TTL safety net, and never share per-user data.

📚 Further Reading

🚀 What's Next?

We've now hardened, throttled, and accelerated REST APIs. Next we step back to compare architectures entirely: GraphQL vs. REST — how a query language changes the shape of the requests you're caching and securing.

🎉 Blazing fast!

You can now cut latency and load dramatically — and know exactly when a cached answer can be trusted.