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 TypeExampleRecommended TTLReasoning
Static configurationFeature flags, app settings5-15 minutesChanges rarely, but you want updates within minutes
User profileName, avatar, preferences5-30 minutesChanges occasionally, stale data is annoying but not critical
Product catalogName, description, images15-60 minutesChanges infrequently, acceptable to be slightly stale
Product priceCurrent price, discounts1-5 minutesChanges during sales, stale prices cause real problems
Inventory/stockItems remaining30-60 secondsChanges frequently, stale data causes overselling
Search resultsQuery results, filters5-15 minutesCan tolerate slight staleness for performance
Dashboard analyticsCharts, aggregates1-5 minutesExpensive to compute, users expect near-real-time
Real-time dataStock prices, live scores5-15 secondsMust be very fresh, short TTL or no cache
Session dataAuth sessions24-48 hoursLong-lived by design, explicit invalidation on logout
API rate limit countersRequest counts60 seconds (window)TTL matches the rate limit window exactly
Computed aggregationsLeaderboards, reports5-60 minutesExpensive 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

TypeUse CaseExample
Fixed TTLData that should refresh periodically regardless of accessProduct prices, analytics dashboards
Sliding TTLData that should stay cached as long as it is being accessedSession 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

DirectiveMeaning
publicAny cache (CDN, proxy) can store this response
privateOnly the browser can cache (not CDN)
max-age=NBrowser cache TTL in seconds
s-maxage=NCDN/proxy cache TTL (overrides max-age for shared caches)
no-cacheCache it, but always revalidate before using
no-storeDo not cache at all — ever
must-revalidateAfter max-age, must revalidate (don't serve stale)
stale-while-revalidate=NServe stale for N seconds while revalidating in background
immutableContent 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

AspectHTTP Caching (headers)Application Caching (Redis)
WhereBrowser + CDNServer-side (Redis)
Who controlsResponse headersApplication code
GranularityPer URL/endpointAny key (custom logic)
InvalidationTTL, ETag, Last-ModifiedDEL, TTL, pub/sub
ScopeSingle user (private) or all users (public)All server instances
Best forStatic assets, public API responsesDatabase query results, computed data
Can cacheFull HTTP responsesAnything (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

EndpointBrowserCDNRedisInvalidation
GET /api/products/featured60s300s600sOn product update
GET /api/products/:id120s600s1800sOn product update
GET /api/me/profile60sNone (private)300sOn profile update
GET /api/me/cartNoneNoneNoneAlways fresh
GET /assets/*1 year1 yearN/AFilename hash changes

12. Key Takeaways

  1. Every cached value needs a TTL — caching without expiration is a bug waiting to happen.
  2. Match TTL to data volatility — prices need short TTL (minutes), static config needs long TTL (hours).
  3. Layer your caches — browser, CDN, application (Redis), each with coordinated TTL values.
  4. Inner layers should have shorter TTL than outer layers to prevent stale data propagation.
  5. Use dynamic TTL for data with variable update frequency — recently changed data gets shorter TTL.
  6. Sliding TTL keeps popular data warm but combine it with a maximum age to prevent infinite staleness.
  7. HTTP Cache-Control is your first defense — it prevents requests from reaching your server at all.
  8. ETag and Last-Modified save bandwidth — 304 Not Modified sends no body.
  9. no-store for sensitive data, no-cache for data that should be revalidated, public + max-age for cacheable content.
  10. CDN + Redis + HTTP headers together give you sub-20ms global response times.

Explain-It Challenge

  1. 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.
  2. 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.
  3. 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