hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Caching with @hectoday/http

Why Caching

  • The Same Query, A Thousand Times
  • Project Setup

HTTP Caching

  • Cache-Control Headers
  • ETags and Conditional Requests
  • Stale-While-Revalidate

Server-Side Caching

  • In-Memory Caching with Map
  • TTL and Expiration
  • Cache-Aside Pattern
  • LRU Eviction

What to Cache

  • Caching Database Queries
  • Caching Computed Results
  • Caching External API Responses

Invalidation

  • The Hardest Problem
  • Time-Based Invalidation
  • Event-Based Invalidation
  • Tag-Based Invalidation

Putting It All Together

  • Caching Checklist
  • Capstone: Caching the Book Catalog

Capstone: Caching the Book Catalog

What we built

A caching system for a book catalog API, applied at every level:

LayerWhat it doesLesson
Cache-ControlBrowser/CDN caches responsesCache-Control Headers
ETags304 Not Modified for unchanged dataETags and Conditional Requests
Stale-While-RevalidateInstant responses during revalidationStale-While-Revalidate
In-memory MapServer-side cache with O(1) accessIn-Memory Caching with Map
TTLAutomatic expiration of cached entriesTTL and Expiration
Cache-asideCheck → miss → query → storeCache-Aside Pattern
LRU evictionBounded memory usageLRU Eviction
Query cachingAvoid expensive JOINs and aggregationsCaching Database Queries
Computed resultsPrecomputed leaderboards and statsCaching Computed Results
External API cacheReduced API calls with fallbackCaching External API Responses
Event-based invalidationDelete cache on writesEvent-Based Invalidation
Tag-based invalidationAutomatic invalidation via dependenciesTag-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 /health with 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

EndpointWithout cachingWith cachingImprovement
GET /books/top15ms (3-table JOIN)<1ms (cache hit)15x
GET /books/:id5ms (JOIN + subqueries)<1ms (cache hit)5x
GET /books3ms (JOIN)<1ms (cache hit)3x
Browser revisit15ms (full response)0ms (browser cache)∞
Browser revalidate15ms (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?

← Caching Checklist Back to course →

© 2026 hectoday. All rights reserved.