Episode 6 — Scaling Reliability Microservices Web3 / 6.6 — Caching in Production
6.6.c — TTL Strategies
In one sentence: TTL (Time To Live) is the expiration timer on cached data — choosing the right TTL for each data type is the difference between a cache that saves your database and one that serves stale data or provides no benefit at all.
Navigation: ← 6.6.b Cache Invalidation · 6.6 Overview
1. What Is TTL?
TTL (Time To Live) is the number of seconds (or milliseconds) a cached value remains valid. When the TTL expires, the key is automatically deleted by Redis (or whatever cache layer you are using), and the next request for that data will trigger a fresh fetch from the source.
// Set a key with a 5-minute (300 second) TTL
await redis.set('user:123', JSON.stringify(userData), 'EX', 300);
// Check remaining TTL
const remaining = await redis.ttl('user:123');
// 300 → just set
// 150 → halfway through
// -1 → no expiry set (key lives forever)
// -2 → key doesn't exist
TTL is your last line of defense against stale data. Even if your event-based invalidation has a bug and misses a key, the TTL will eventually expire it. That is why every production cache key should have a TTL — never cache without expiration.
2. Choosing TTL Values by Data Type
Different data has different freshness requirements. Here is a practical guide:
| Data Type | Example | Recommended TTL | Reasoning |
|---|---|---|---|
| Static configuration | Feature flags, app settings | 5-15 minutes | Changes rarely, but you want updates within minutes |
| User profile | Name, avatar, preferences | 5-30 minutes | Changes occasionally, stale data is annoying but not critical |
| Product catalog | Name, description, images | 15-60 minutes | Changes infrequently, acceptable to be slightly stale |
| Product price | Current price, discounts | 1-5 minutes | Changes during sales, stale prices cause real problems |
| Inventory/stock | Items remaining | 30-60 seconds | Changes frequently, stale data causes overselling |
| Search results | Query results, filters | 5-15 minutes | Can tolerate slight staleness for performance |
| Dashboard analytics | Charts, aggregates | 1-5 minutes | Expensive to compute, users expect near-real-time |
| Real-time data | Stock prices, live scores | 5-15 seconds | Must be very fresh, short TTL or no cache |
| Session data | Auth sessions | 24-48 hours | Long-lived by design, explicit invalidation on logout |
| API rate limit counters | Request counts | 60 seconds (window) | TTL matches the rate limit window exactly |
| Computed aggregations | Leaderboards, reports | 5-60 minutes | Expensive to recompute, users tolerate lag |
3. Short vs Long TTL Tradeoffs
Short TTL (seconds to low minutes):
✅ Data is always fresh
✅ Stale data window is tiny
❌ Cache hit rate drops (more misses)
❌ Database load stays higher
❌ Increased latency for misses
Long TTL (hours to days):
✅ Cache hit rate is very high (95%+)
✅ Database is barely touched
✅ Consistent low latency for most requests
❌ Users may see outdated data
❌ Must rely on event-based invalidation for freshness
❌ More memory used by long-lived keys
Visualizing the tradeoff
Cache Hit Rate vs TTL:
Hit Rate
100% │ ╭─────────────────
│ ╭───╯
90% │ ╭───╯
│ ╭───╯
80% │ ╭───╯
│ ╭───╯
70% │──╯
│
60% │
└──────────────────────────────────────────────
0s 30s 1m 5m 15m 30m 1h 6h TTL
The "sweet spot" for most data is 5-30 minutes:
high hit rate without unacceptable staleness.
4. Dynamic TTL Based on Data Freshness
Instead of a fixed TTL for all items of the same type, adjust the TTL based on how recently the data was updated:
function calculateDynamicTTL(lastUpdated, baseTTL = 3600) {
const age = Date.now() - new Date(lastUpdated).getTime();
const ageInHours = age / (1000 * 60 * 60);
if (ageInHours < 1) {
// Recently updated → short TTL (might change again soon)
return Math.max(60, baseTTL * 0.1); // 10% of base, minimum 60s
} else if (ageInHours < 24) {
// Updated today → moderate TTL
return baseTTL * 0.5; // 50% of base
} else {
// Not updated recently → long TTL (stable data)
return baseTTL; // Full base TTL
}
}
// Usage
async function cacheProduct(product) {
const ttl = calculateDynamicTTL(product.updatedAt, 3600);
await redis.set(
`product:${product._id}`,
JSON.stringify(product),
'EX',
ttl
);
console.log(`Cached product ${product._id} with TTL=${ttl}s`);
}
Why dynamic TTL? A product that was updated 5 minutes ago is more likely to be updated again soon (an admin is actively editing it). A product that hasn't changed in 30 days is unlikely to change in the next hour. Dynamic TTL adapts to this reality.
5. Sliding TTL vs Fixed TTL
Fixed TTL
The timer starts when the key is created and counts down regardless of access.
t=0 SET key value EX 300 (TTL = 300s)
t=100 GET key (TTL = 200s remaining — not reset)
t=200 GET key (TTL = 100s remaining — not reset)
t=300 Key expires automatically
Sliding TTL
The timer resets every time the key is accessed. Popular keys stay alive; unpopular keys expire naturally.
async function getWithSlidingTTL(key, ttl = 300) {
const value = await redis.get(key);
if (value) {
// Reset the TTL on every read (sliding window)
await redis.expire(key, ttl);
return JSON.parse(value);
}
return null;
}
t=0 SET key value EX 300 (TTL = 300s)
t=100 GET key + EXPIRE 300 (TTL reset to 300s)
t=350 GET key + EXPIRE 300 (TTL reset to 300s — would have expired at t=400)
t=650 Key expires (no access for 300s)
When to use each
| Type | Use Case | Example |
|---|---|---|
| Fixed TTL | Data that should refresh periodically regardless of access | Product prices, analytics dashboards |
| Sliding TTL | Data that should stay cached as long as it is being accessed | Session data, active user profiles, currently viewed items |
Danger of sliding TTL: A very popular key might never expire, meaning stale data persists indefinitely. Always combine sliding TTL with either event-based invalidation or a maximum absolute TTL.
async function getWithBoundedSlidingTTL(key, slidingTTL = 300, maxTTL = 3600) {
const value = await redis.get(key);
if (!value) return null;
const createdAt = await redis.get(`${key}:created`);
const age = Date.now() - parseInt(createdAt || '0');
if (age > maxTTL * 1000) {
// Exceeded maximum age — force expiration
await redis.del(key);
await redis.del(`${key}:created`);
return null;
}
// Reset sliding TTL
await redis.expire(key, slidingTTL);
return JSON.parse(value);
}
6. TTL for Different Caching Layers
Production applications have multiple caching layers, each with its own TTL:
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Browser Cache (client-side) │
│ TTL: Set via HTTP Cache-Control header │
│ Who controls: Server (via response headers) │
│ Example: Cache-Control: max-age=300 │
│ Scope: Single user's browser │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: CDN Cache (edge network) │
│ TTL: Set via Cache-Control or CDN config │
│ Who controls: Server headers + CDN rules │
│ Example: Cache-Control: s-maxage=600 │
│ Scope: All users in a geographic region │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: Application Cache (Redis) │
│ TTL: Set in code via redis.set('key', val, 'EX', ttl) │
│ Who controls: Application code │
│ Example: 300 seconds for API responses │
│ Scope: All app servers (shared) │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: Database Query Cache │
│ TTL: Managed by database (if supported) │
│ Who controls: Database config or ORM │
│ Example: MongoDB query plan cache │
│ Scope: Single database instance │
└─────────────────────────────────────────────────────────────┘
TTL coordination rule: Each inner layer should have a shorter or equal TTL compared to the outer layer. Otherwise, the outer layer serves stale data long after the inner layer has refreshed.
GOOD: Browser 60s → CDN 120s → Redis 300s → DB
(Each layer is fresher or equal)
BAD: Browser 3600s → CDN 60s → Redis 300s → DB
(Browser serves stale data for up to 1 hour after CDN refreshes)
7. Preventing Stale/Outdated Cached Data
A comprehensive anti-staleness strategy combines multiple techniques:
class ProductCache {
constructor(redis, db) {
this.redis = redis;
this.db = db;
this.BASE_TTL = 900; // 15 minutes
}
async get(productId) {
const key = `product:${productId}`;
const cached = await this.redis.get(key);
if (cached) {
const data = JSON.parse(cached);
// Defense 1: Check data age within cached object
const age = Date.now() - data._cachedAt;
if (age > this.BASE_TTL * 1000 * 1.5) {
// Data is older than expected — force refresh
await this.redis.del(key);
return this.fetchAndCache(productId);
}
return data;
}
return this.fetchAndCache(productId);
}
async fetchAndCache(productId) {
const product = await this.db.collection('products').findOne({ _id: productId });
if (!product) return null;
// Defense 2: Embed metadata in cached object
const cacheEntry = {
...product,
_cachedAt: Date.now(),
_version: product.updatedAt?.getTime() || 0,
};
// Defense 3: Dynamic TTL based on update frequency
const ttl = this.calculateTTL(product);
await this.redis.set(
`product:${productId}`,
JSON.stringify(cacheEntry),
'EX',
ttl
);
return cacheEntry;
}
calculateTTL(product) {
const hoursSinceUpdate = (Date.now() - new Date(product.updatedAt).getTime()) / 3_600_000;
if (hoursSinceUpdate < 1) return 120; // 2 minutes for recently changed
if (hoursSinceUpdate < 24) return 600; // 10 minutes for today's changes
return this.BASE_TTL; // 15 minutes for stable data
}
// Defense 4: Event-based invalidation
async invalidate(productId) {
await this.redis.del(`product:${productId}`);
}
}
8. Cache Headers: HTTP Caching
HTTP provides built-in caching mechanisms via response headers. Understanding these is essential for production systems.
Cache-Control
The most important HTTP caching header. It tells browsers and CDNs how to cache a response.
// Express.js examples
// Public, cacheable by CDN and browser for 5 minutes
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=300, s-maxage=600');
// max-age=300 → Browser caches for 5 minutes
// s-maxage=600 → CDN caches for 10 minutes
// public → CDN is allowed to cache this
res.json(products);
});
// Private, only browser can cache (contains user-specific data)
app.get('/api/me/profile', (req, res) => {
res.set('Cache-Control', 'private, max-age=60');
// private → CDN must NOT cache this (user-specific)
// max-age=60 → Browser caches for 1 minute
res.json(userProfile);
});
// Never cache (sensitive or real-time data)
app.get('/api/me/balance', (req, res) => {
res.set('Cache-Control', 'no-store');
// no-store → Neither browser nor CDN may cache this response
res.json({ balance: 1250.50 });
});
// Cache but always revalidate with server before using
app.get('/api/products/:id', (req, res) => {
res.set('Cache-Control', 'no-cache');
// no-cache → Cache the response, but ALWAYS check with server
// before using (via ETag or Last-Modified)
res.json(product);
});
// Stale-while-revalidate (serve stale, refresh in background)
app.get('/api/feed', (req, res) => {
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
// max-age=60 → Fresh for 1 minute
// stale-while-revalidate=300 → After max-age, serve stale for up to 5 more
// minutes while revalidating in background
res.json(feedData);
});
Cache-Control directives summary
| Directive | Meaning |
|---|---|
public | Any cache (CDN, proxy) can store this response |
private | Only the browser can cache (not CDN) |
max-age=N | Browser cache TTL in seconds |
s-maxage=N | CDN/proxy cache TTL (overrides max-age for shared caches) |
no-cache | Cache it, but always revalidate before using |
no-store | Do not cache at all — ever |
must-revalidate | After max-age, must revalidate (don't serve stale) |
stale-while-revalidate=N | Serve stale for N seconds while revalidating in background |
immutable | Content will never change (use for hashed assets) |
ETag (Entity Tag)
An ETag is a fingerprint of the response. The browser sends it back on the next request, and the server can respond with 304 Not Modified (no body) if the content hasn't changed.
import crypto from 'crypto';
app.get('/api/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
const body = JSON.stringify(product);
// Generate ETag from content hash
const etag = `"${crypto.createHash('md5').update(body).digest('hex')}"`;
// Check if client already has this version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Not Modified — no body sent
}
res.set('ETag', etag);
res.set('Cache-Control', 'no-cache'); // Always revalidate
res.json(product);
});
First request:
Client → GET /api/products/123
Server → 200 OK, ETag: "abc123", Body: { ... } (full response)
Second request:
Client → GET /api/products/123, If-None-Match: "abc123"
Server → 304 Not Modified, No body (saves bandwidth)
After data changes:
Client → GET /api/products/123, If-None-Match: "abc123"
Server → 200 OK, ETag: "def456", Body: { ... } (new data)
Last-Modified
Similar to ETag but uses timestamps instead of content hashes.
app.get('/api/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
const lastModified = new Date(product.updatedAt).toUTCString();
// Check if client's cached version is still current
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince && new Date(ifModifiedSince) >= new Date(product.updatedAt)) {
return res.status(304).end();
}
res.set('Last-Modified', lastModified);
res.set('Cache-Control', 'no-cache');
res.json(product);
});
9. HTTP Caching vs Application Caching
| Aspect | HTTP Caching (headers) | Application Caching (Redis) |
|---|---|---|
| Where | Browser + CDN | Server-side (Redis) |
| Who controls | Response headers | Application code |
| Granularity | Per URL/endpoint | Any key (custom logic) |
| Invalidation | TTL, ETag, Last-Modified | DEL, TTL, pub/sub |
| Scope | Single user (private) or all users (public) | All server instances |
| Best for | Static assets, public API responses | Database query results, computed data |
| Can cache | Full HTTP responses | Anything (objects, lists, counts) |
In production, you use both:
Client → Browser Cache (HTTP) → CDN Cache (HTTP) → App Server → Redis Cache → Database
Layer 1 Layer 2 Layer 3 Layer 4 Source
Each layer catches requests before they reach the next, reducing load on deeper layers.
10. CDN Caching Basics
A CDN (Content Delivery Network) caches your content at edge servers around the world, serving responses from the server closest to the user.
Without CDN:
User in Tokyo → Request travels to server in Virginia → 200ms latency
With CDN:
User in Tokyo → Request served from CDN edge in Tokyo → 20ms latency
Setting up CDN caching for an API
// Express middleware for CDN cache headers
function cdnCache(maxAge, sMaxAge) {
return (req, res, next) => {
if (req.method === 'GET') {
res.set('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
// Vary header tells CDN to cache different versions for different
// Accept-Encoding values (gzip vs brotli) and Accept-Language
res.set('Vary', 'Accept-Encoding');
}
next();
};
}
// Public data — aggressive CDN caching
app.get('/api/products', cdnCache(60, 300), async (req, res) => {
const products = await getProducts();
res.json(products);
});
// Static assets — immutable caching (hashed filenames)
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true,
setHeaders: (res, path) => {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
},
}));
CDN invalidation
When you need to force-refresh CDN-cached content:
// Most CDNs provide an API for cache purging
// Example with a generic CDN API:
async function purgeCDNCache(urls) {
await fetch('https://api.cdn-provider.com/v1/purge', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CDN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ urls }),
});
}
// After updating a product:
await purgeCDNCache([
'https://api.example.com/api/products',
`https://api.example.com/api/products/${productId}`,
]);
11. Complete Caching Strategy for a Production App
Here is a full, layered caching strategy for an e-commerce application:
import express from 'express';
import Redis from 'ioredis';
const app = express();
const redis = new Redis();
// ──────────────────────────────────────────────────────────
// Layer 1: HTTP Cache Headers (browser + CDN)
// ──────────────────────────────────────────────────────────
function httpCache(options = {}) {
const {
visibility = 'public', // 'public' | 'private'
maxAge = 0, // Browser cache (seconds)
sMaxAge = 0, // CDN cache (seconds)
staleWhileRevalidate = 0,
immutable = false,
} = options;
return (req, res, next) => {
if (req.method !== 'GET') return next();
const parts = [`${visibility}`, `max-age=${maxAge}`];
if (sMaxAge > 0) parts.push(`s-maxage=${sMaxAge}`);
if (staleWhileRevalidate > 0) parts.push(`stale-while-revalidate=${staleWhileRevalidate}`);
if (immutable) parts.push('immutable');
res.set('Cache-Control', parts.join(', '));
next();
};
}
// ──────────────────────────────────────────────────────────
// Layer 2: Redis Application Cache
// ──────────────────────────────────────────────────────────
function redisCache(ttl = 300) {
return async (req, res, next) => {
if (req.method !== 'GET') return next();
const key = `apicache:${req.originalUrl}`;
try {
const cached = await redis.get(key);
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
}
} catch (err) {
console.error('Redis read error:', err.message);
}
res.set('X-Cache', 'MISS');
const originalJson = res.json.bind(res);
res.json = (data) => {
redis.set(key, JSON.stringify(data), 'EX', ttl).catch(console.error);
return originalJson(data);
};
next();
};
}
// ──────────────────────────────────────────────────────────
// Route configuration with layered caching
// ──────────────────────────────────────────────────────────
// Homepage products: public, CDN 5min, browser 1min, Redis 10min
app.get('/api/products/featured',
httpCache({ visibility: 'public', maxAge: 60, sMaxAge: 300, staleWhileRevalidate: 120 }),
redisCache(600),
async (req, res) => {
const products = await db.collection('products').find({ featured: true }).toArray();
res.json(products);
}
);
// Individual product: public, CDN 10min, browser 2min, Redis 30min
app.get('/api/products/:id',
httpCache({ visibility: 'public', maxAge: 120, sMaxAge: 600, staleWhileRevalidate: 300 }),
redisCache(1800),
async (req, res) => {
const product = await db.collection('products').findOne({ _id: req.params.id });
if (!product) return res.status(404).json({ error: 'Not found' });
res.json(product);
}
);
// User profile: private (user-specific), browser 1min, Redis 5min
app.get('/api/me/profile',
httpCache({ visibility: 'private', maxAge: 60 }),
async (req, res) => {
const key = `user:${req.userId}:profile`;
const cached = await redis.get(key);
if (cached) return res.json(JSON.parse(cached));
const profile = await db.collection('users').findOne({ _id: req.userId });
await redis.set(key, JSON.stringify(profile), 'EX', 300);
res.json(profile);
}
);
// Cart: no caching (real-time accuracy required)
app.get('/api/me/cart', (req, res) => {
res.set('Cache-Control', 'no-store');
// Always fetch fresh from database
});
// Static assets: immutable (hashed filenames never change)
app.use('/assets',
httpCache({ visibility: 'public', maxAge: 31536000, immutable: true }),
express.static('public/assets')
);
// ──────────────────────────────────────────────────────────
// Invalidation on writes
// ──────────────────────────────────────────────────────────
app.put('/api/products/:id', async (req, res) => {
await db.collection('products').updateOne(
{ _id: req.params.id },
{ $set: req.body }
);
// Invalidate Redis caches
await redis.del(`apicache:/api/products/${req.params.id}`);
await redis.del('apicache:/api/products/featured');
// Purge CDN cache (if applicable)
// await purgeCDNCache([...]);
res.json({ success: true });
});
app.listen(3000);
TTL summary for this app
| Endpoint | Browser | CDN | Redis | Invalidation |
|---|---|---|---|---|
GET /api/products/featured | 60s | 300s | 600s | On product update |
GET /api/products/:id | 120s | 600s | 1800s | On product update |
GET /api/me/profile | 60s | None (private) | 300s | On profile update |
GET /api/me/cart | None | None | None | Always fresh |
GET /assets/* | 1 year | 1 year | N/A | Filename hash changes |
12. Key Takeaways
- Every cached value needs a TTL — caching without expiration is a bug waiting to happen.
- Match TTL to data volatility — prices need short TTL (minutes), static config needs long TTL (hours).
- Layer your caches — browser, CDN, application (Redis), each with coordinated TTL values.
- Inner layers should have shorter TTL than outer layers to prevent stale data propagation.
- Use dynamic TTL for data with variable update frequency — recently changed data gets shorter TTL.
- Sliding TTL keeps popular data warm but combine it with a maximum age to prevent infinite staleness.
- HTTP Cache-Control is your first defense — it prevents requests from reaching your server at all.
- ETag and Last-Modified save bandwidth —
304 Not Modifiedsends no body. no-storefor sensitive data,no-cachefor data that should be revalidated,public+max-agefor cacheable content.- CDN + Redis + HTTP headers together give you sub-20ms global response times.
Explain-It Challenge
- A manager asks "why is the product still showing the old price 10 minutes after we updated it?" Explain which caching layers could be responsible and how to fix each one.
- Your API sends
Cache-Control: public, max-age=3600. A user complains they cannot see a change you just deployed. Explain why and propose a fix that still allows caching. - Design a TTL strategy for a social media feed where posts are created every few seconds but most users check the feed every few minutes.
Navigation: ← 6.6.b Cache Invalidation · 6.6 Overview