Time-Based Invalidation
The simplest strategy
Time-based invalidation does not track what changed. It says: “After N seconds, this data might be stale. Throw it away and fetch fresh data.” The TTL lesson (Section 3) implemented this. This lesson explains when and how to tune it.
TTL is “good enough” for most data
The previous lesson listed the problems with invalidation: knowing what to invalidate, race conditions, distributed systems. TTL sidesteps all of them:
Knowing what to invalidate: You do not need to know. Every entry expires on its own schedule.
Race conditions: Cannot happen. The cache is never explicitly invalidated — it just expires.
Distributed systems: Each server’s cache expires independently. No coordination needed.
The tradeoff: data can be stale for up to the TTL duration. With a 60-second TTL, a review posted at second 0 is not reflected until second 60. For most applications, this is acceptable.
Choosing TTL by data type
const TTL = {
// Rarely changes, not time-sensitive
BOOK_DETAIL: 10 * 60_000, // 10 minutes
AUTHOR_LIST: 15 * 60_000, // 15 minutes
CATALOG_STATS: 30 * 60_000, // 30 minutes
// Changes occasionally, moderate sensitivity
TOP_BOOKS: 5 * 60_000, // 5 minutes
BOOK_LIST: 2 * 60_000, // 2 minutes
GENRE_LIST: 5 * 60_000, // 5 minutes
// Changes frequently or is time-sensitive
SEARCH_RESULTS: 30_000, // 30 seconds
RECENT_REVIEWS: 60_000, // 1 minute
// External APIs (reduce API calls)
EXTERNAL_RATING: 24 * 60 * 60_000, // 24 hours
}; The TTL decision framework
Ask three questions:
- How often does this data change? If it changes every hour, a 24-hour TTL is too long.
- How bad is staleness? If a user sees yesterday’s book count, is that a problem? Probably not. If they see yesterday’s price, maybe.
- How expensive is the query? If the query takes 100ms, a short TTL means frequent re-computation. If it takes 1ms, TTL barely matters.
| Change frequency | Staleness tolerance | Query cost | TTL |
|---|---|---|---|
| Rarely | High | High | 30-60 minutes |
| Occasionally | Medium | Medium | 2-10 minutes |
| Frequently | Low | Low | 15-60 seconds |
| Real-time | None | Any | Do not cache |
Different TTLs at different levels
HTTP caching and server-side caching can have different TTLs:
route.get("/books/top", {
resolve: async () => {
// Server-side: cache query result for 5 minutes
const books = await cacheThrough("top-books", 5 * 60_000, () => db.prepare("SELECT ...").all());
// HTTP: browser caches for 1 minute, stale-while-revalidate for 5 minutes
return new Response(JSON.stringify(books), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
},
});
},
}); The browser cache (60 seconds) is shorter than the server cache (5 minutes). This means the browser revalidates frequently (every 60 seconds), but most revalidations hit the server cache instead of the database.
Exercises
Exercise 1: Apply the TTL constants to all cached endpoints. Use different TTLs for different data types.
Exercise 2: Set a 10-second TTL. Post a review. Call the endpoint every second. At which second does the new review appear?
Exercise 3: Set different HTTP max-age and server-side TTL values. Trace a request through both cache layers.
Why is TTL-based invalidation sufficient for most applications?