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

Tag-Based Invalidation

The manual tracking problem

The Event-Based Invalidation lesson showed hard-coded invalidation:

cacheDelete(`book:${bookId}`);
cacheDelete("top-books");
cacheDelete("catalog-stats");
cacheDelete("leaderboard:most-reviewed");

This is fragile. Add a new endpoint that caches book data? You must remember to add it to every write path. Forget one? Stale data.

Tag-based invalidation solves this: tag cache entries with the entities they depend on. When an entity changes, invalidate all entries with that tag — no need to track individual keys.

How tags work

When caching data, tag the entry with the entities it depends on:

// "top-books" depends on books and reviews
cacheSetWithTags("top-books", books, 5 * 60_000, ["books", "reviews"]);

// "book:book-1" depends on book-1 and its reviews
cacheSetWithTags("book:book-1", book, 10 * 60_000, ["book:book-1", "reviews:book-1"]);

// "catalog-stats" depends on books, authors, and reviews
cacheSetWithTags("catalog-stats", stats, 30 * 60_000, ["books", "authors", "reviews"]);

When a review is posted for book-1, invalidate the tags:

invalidateByTag("reviews"); // Clears: top-books, catalog-stats
invalidateByTag("reviews:book-1"); // Clears: book:book-1

Every cache entry tagged with "reviews" or "reviews:book-1" is deleted. You do not need to know which specific keys to delete — the tags handle it.

Implementation

// src/tagged-cache.ts
interface TaggedCacheEntry<T> {
  value: T;
  expiresAt: number;
  tags: string[];
}

const cache = new Map<string, TaggedCacheEntry<any>>();
const tagIndex = new Map<string, Set<string>>(); // tag → set of cache keys

export function cacheSetWithTags<T>(key: string, value: T, ttlMs: number, tags: string[]): void {
  // Remove old entry's tag associations if replacing
  const existing = cache.get(key);
  if (existing) {
    for (const tag of existing.tags) {
      tagIndex.get(tag)?.delete(key);
    }
  }

  // Store the entry
  cache.set(key, { value, expiresAt: Date.now() + ttlMs, tags });

  // Index by tags
  for (const tag of tags) {
    if (!tagIndex.has(tag)) tagIndex.set(tag, new Set());
    tagIndex.get(tag)!.add(key);
  }
}

export function cacheGetTagged<T>(key: string): T | undefined {
  const entry = cache.get(key);
  if (!entry) return undefined;

  if (Date.now() > entry.expiresAt) {
    // Expired — clean up
    for (const tag of entry.tags) {
      tagIndex.get(tag)?.delete(key);
    }
    cache.delete(key);
    return undefined;
  }

  return entry.value;
}

export function invalidateByTag(tag: string): number {
  const keys = tagIndex.get(tag);
  if (!keys) return 0;

  let count = 0;
  for (const key of keys) {
    const entry = cache.get(key);
    if (entry) {
      // Remove from all tag indexes
      for (const t of entry.tags) {
        tagIndex.get(t)?.delete(key);
      }
      cache.delete(key);
      count++;
    }
  }

  tagIndex.delete(tag);
  return count;
}

The tagIndex maps each tag to the set of cache keys that depend on it. invalidateByTag finds all keys for the tag and deletes them.

Using tags in the application

// Caching with tags
route.get("/books/top", {
  resolve: async () => {
    const cached = cacheGetTagged<any[]>("top-books");
    if (cached) return Response.json(cached);

    const books = db.prepare("SELECT ...").all();
    cacheSetWithTags("top-books", books, 5 * 60_000, ["books", "reviews"]);
    return Response.json(books);
  },
});

route.get("/books/:id", {
  resolve: async (c) => {
    const id = c.input.params.id as string;
    const cached = cacheGetTagged<any>(`book:${id}`);
    if (cached) return Response.json(cached);

    const book = db.prepare("SELECT ...").get(id);
    if (!book) return Response.json({ error: "Not found" }, { status: 404 });
    cacheSetWithTags(`book:${id}`, book, 10 * 60_000, [`book:${id}`, "books", `reviews:${id}`]);
    return Response.json(book);
  },
});

// Invalidation — clean and simple
route.post("/books/:id/reviews", {
  resolve: async (c) => {
    const bookId = c.input.params.id as string;
    db.prepare("INSERT INTO reviews ...").run(/* ... */);

    // Two lines invalidate everything that depends on reviews for this book
    invalidateByTag("reviews");
    invalidateByTag(`reviews:${bookId}`);

    return Response.json({ status: "created" }, { status: 201 });
  },
});

route.put("/books/:id", {
  resolve: async (c) => {
    const bookId = c.input.params.id as string;
    db.prepare("UPDATE books SET ...").run(/* ... */);

    // Invalidate everything that depends on this book
    invalidateByTag(`book:${bookId}`);
    invalidateByTag("books");

    return Response.json({ status: "updated" });
  },
});

Adding a new cached endpoint that depends on reviews? Tag it with "reviews". It will be automatically invalidated when reviews change — no need to update the write endpoints.

Tag naming conventions

Use a consistent scheme:

// Entity type (broad): invalidates all entries for that type
"books"; // All book-related caches
"reviews"; // All review-related caches
"authors"; // All author-related caches

// Entity instance (specific): invalidates entries for one record
"book:book-1"; // Caches specific to book-1
"reviews:book-1"; // Caches for book-1's reviews
"author:author-1"; // Caches specific to author-1

Broad tags for broad changes (new book added → invalidate all listings). Specific tags for specific changes (review posted for book-1 → invalidate book-1’s detail).

Exercises

Exercise 1: Implement the tagged cache. Tag /books/top with ["books", "reviews"]. Tag /books/:id with ["book:ID", "reviews:ID"]. Verify both endpoints cache correctly.

Exercise 2: Post a review. Call invalidateByTag("reviews"). Verify /books/top is cleared but /books/:id for a different book is not.

Exercise 3: Add a new cached endpoint (e.g., /genres/:genre/books). Tag it with ["books"]. Verify it is automatically invalidated when a book is added — without modifying the book creation endpoint.

What is the main advantage of tag-based invalidation over manually tracking cache keys?

← Event-Based Invalidation Caching Checklist →

© 2026 hectoday. All rights reserved.