Capstone: Caching the Book Catalog
What we built
A caching system for a book catalog API, applied at every level:
| Layer | What it does | Lesson |
|---|---|---|
| Cache-Control | Browser/CDN caches responses | Cache-Control Headers |
| ETags | 304 Not Modified for unchanged data | ETags and Conditional Requests |
| Stale-While-Revalidate | Instant responses during revalidation | Stale-While-Revalidate |
| In-memory Map | Server-side cache with O(1) access | In-Memory Caching with Map |
| TTL | Automatic expiration of cached entries | TTL and Expiration |
| Cache-aside | Check → miss → query → store | Cache-Aside Pattern |
| LRU eviction | Bounded memory usage | LRU Eviction |
| Query caching | Avoid expensive JOINs and aggregations | Caching Database Queries |
| Computed results | Precomputed leaderboards and stats | Caching Computed Results |
| External API cache | Reduced API calls with fallback | Caching External API Responses |
| Event-based invalidation | Delete cache on writes | Event-Based Invalidation |
| Tag-based invalidation | Automatic invalidation via dependencies | Tag-Based Invalidation |
The caching architecture
Browser request
│
├─ Browser cache (Cache-Control)
│ ├─ Fresh? → Serve from browser (no request)
│ └─ Stale? → Send If-None-Match to server
│
├─ Server receives request
│ ├─ ETag matches? → 304 Not Modified (no body)
│ └─ ETag different or missing? → Continue
│
├─ Server-side cache (LRU + TTL + tags)
│ ├─ Cache hit? → Return cached data
│ └─ Cache miss? → Query database → Store in cache
│
├─ Response headers set
│ ├─ Cache-Control: public, max-age=60, stale-while-revalidate=300
│ └─ ETag: "abc123"
│
└─ Write operation?
└─ Invalidate by tags → Affected entries cleared The complete cached endpoint
import { conditionalResponse } from "./etag.js";
import { cacheGetTagged, cacheSetWithTags } from "./tagged-cache.js";
route.get("/books/top", {
resolve: (c) => {
// Layer 1: Server-side cache
let books = cacheGetTagged<any[]>("top-books");
if (!books) {
// Cache miss: query database
books = db
.prepare(
`
SELECT books.id, books.title, authors.name AS author_name,
AVG(reviews.rating) AS avg_rating, COUNT(reviews.id) AS review_count
FROM books
JOIN authors ON books.author_id = authors.id
JOIN reviews ON reviews.book_id = books.id
GROUP BY books.id
ORDER BY avg_rating DESC, review_count DESC
LIMIT 10
`,
)
.all();
// Store with tags
cacheSetWithTags("top-books", books, 5 * 60_000, ["books", "reviews"]);
}
// Layer 2: HTTP caching (ETag + Cache-Control)
return conditionalResponse(c.request, books, "public, max-age=60, stale-while-revalidate=300");
},
}); Three cache layers in one endpoint: browser cache (max-age), conditional response (ETag/304), and server-side cache (tagged LRU).
The complete write handler with invalidation
route.post("/books/:id/reviews", {
request: { body: CreateReviewBody },
resolve: (c) => {
if (!c.input.ok) {
return Response.json(
{ error: "Validation failed", details: c.input.issues },
{ status: 400 },
);
}
const bookId = c.input.params.id as string;
// Insert the review
db.prepare(
"INSERT INTO reviews (id, book_id, user_id, rating, body) VALUES (?, ?, ?, ?, ?)",
).run(
crypto.randomUUID(),
bookId,
c.input.body.userId,
c.input.body.rating,
c.input.body.body ?? null,
);
// Tag-based invalidation — two lines clear everything affected
invalidateByTag("reviews");
invalidateByTag(`reviews:${bookId}`);
return Response.json({ status: "created" }, { status: 201 });
},
}); Cache monitoring
// Track cache performance
let hits = 0;
let misses = 0;
// Wrap cacheGet to count hits/misses
export function trackedCacheGet<T>(key: string): T | undefined {
const result = cacheGetTagged<T>(key);
if (result !== undefined) {
hits++;
} else {
misses++;
}
return result;
}
route.get("/admin/cache/stats", {
resolve: () => {
const total = hits + misses;
return Response.json({
hits,
misses,
total,
hitRate: total > 0 ? `${((hits / total) * 100).toFixed(1)}%` : "N/A",
cacheSize: cache.size,
});
},
}); [!NOTE] The Error Handling course’s Health Checks lesson built
/healthwith dependency checks. The Background Jobs course added queue health. This cache stats endpoint continues the pattern — exposing operational health for every subsystem.
Project structure
src/
app.ts # Hectoday HTTP setup, routes
server.ts # HTTP server
db.ts # Database schema and connection
cache.ts # Basic cache (Map + TTL)
lru-cache.ts # LRU cache with bounded size
tagged-cache.ts # Tag-based cache with invalidation
cache-headers.ts # Cache-Control helper (withCacheHeaders)
etag.ts # ETag generation and conditional responses
routes/
books.ts # Book CRUD with caching
reviews.ts # Review creation with invalidation
admin.ts # Cache stats endpoint Performance impact
| Endpoint | Without caching | With caching | Improvement |
|---|---|---|---|
| GET /books/top | 15ms (3-table JOIN) | <1ms (cache hit) | 15x |
| GET /books/:id | 5ms (JOIN + subqueries) | <1ms (cache hit) | 5x |
| GET /books | 3ms (JOIN) | <1ms (cache hit) | 3x |
| Browser revisit | 15ms (full response) | 0ms (browser cache) | ∞ |
| Browser revalidate | 15ms (full response) | <1ms (304, no body) | 15x |
Challenges
Challenge 1: Add a cache warm-up script. On server start, pre-populate the cache with the most common queries (top books, all genres, popular books). Ensure the first visitor gets cache hits, not misses.
Challenge 2: Add per-user caching. Cache user-specific data (reading lists, bookmarks) with the user ID in the cache key and private Cache-Control. Invalidate when the user modifies their data.
Challenge 3: Add a CDN layer. Research how CDNs like Cloudflare use Cache-Control headers. What changes in your caching strategy when a CDN sits between the user and your server?
Challenge 4: Add cache metrics to the health check. If the hit rate drops below 50%, report the cache as "degraded". If the cache size exceeds the LRU limit consistently, report it as "warning".
Why does the capstone use three cache layers (browser, ETag, server-side) instead of just one?
When a review is posted, why does the capstone invalidate by tag instead of deleting specific cache keys?