Book Catalog API with Layered Caching

Build a Hono API on Bun with in-memory caching and ETag headers.

Introduction

30 Second Summary

Every time you refresh a page or re-send a request, the server behind it might be doing the exact same work it already did a moment ago. Caching is how developers skip that repeated effort and make things feel instant.

In this project, you will build a book catalog API using Hono and Bun that starts painfully slow and progressively gets faster as you layer on different caching strategies.

What You'll Build

Hit your API with the same request twice and watch the second one return instantly from cache, complete with response headers that prove exactly what happened.

By the end of this project, you'll have:

  • An in-memory cache that drops your /api/books response time from a full second to under 5ms, with X-Cache headers showing exactly when the cache is working.
  • A cache invalidation system that automatically clears stale data when you update a book, so the next request always returns fresh results.
  • ETag-based HTTP caching that returns 304 Not Modified with no body when the client already has the latest data, saving bandwidth on unchanged responses.
  • Secret Mission: a /cache/stats endpoint that tracks hit rate, miss rate, and invalidation count in real time.

Are there any prerequisites?

This project assumes you're familiar with TypeScript basics and have some experience with backend APIs. No caching experience is required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Hono on Bun

You need a running API server before you can cache anything. Right now, there is no project at all.

In this step, you will scaffold a Hono project on Bun, populate it with a book catalog data layer that simulates a painfully slow database, and hit the API to feel that delay firsthand. That slowness is the problem the rest of the project will solve.

In this step, get ready to:
  • Scaffold a Hono project using Bun and confirm it serves the default page.
  • Build the book catalog data layer with simulated database latency.
  • Test the API endpoints and observe the 1-second delay on every request.
Scaffold the Hono project

Hono's official scaffold command creates a ready-to-run project with hot-reloading built in, so you can iterate fast as you add caching later.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Hono project by running this command:
bun create hono@latest book-catalog-api

What will this command ask you?

The scaffold is interactive. It will ask you three questions:

  • "Which template do you want to use?" Select bun.
  • "Do you want to install project dependencies?" Select Yes.
  • "Which package manager do you want to use?" Select bun.
  • Move into the new project folder by running:
cd book-catalog-api
  • Start the development server by running:
bun run dev

Open your browser and visit http://localhost:3000. You should see the text "Hello Hono!" on the page.

Don't see Hello Hono?

  • Make sure you selected the bun template during scaffolding. If you picked a different template (like cloudflare-workers), delete the folder and re-run the scaffold command.
  • Check that you ran bun run dev from inside the book-catalog-api folder, not the Desktop.
  • help me troubleshoot why my Hono dev server is not running on localhost:3000
  • Stop the dev server by pressing Ctrl+C in the terminal.
  • Open the project folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the book-catalog-api folder on your Desktop, then click Open.

Trust the authors

If VS Code asks whether you trust the authors of the files in this folder, click Yes, I trust the authors.

Replace the starter code with the book catalog data layer

The scaffold gave you a basic "Hello Hono!" app. Now you will replace it with a book catalog that simulates a real database. Every data function will sleep for a full second before returning, mimicking a slow network call.

  • In the Explorer sidebar, right-click the src folder and select New File.
  • Name the file data.ts and press Enter.
  • Paste the following code into data.ts to define the Book interface, seed the catalog, and create the latency helper:
// Define the shape of a book record
export interface Book {
  id: string;
  title: string;
  author: string;
  year: number;
}

// Seed the catalog with five classic programming books
const books: Map<string, Book> = new Map([
  ["1", { id: "1", title: "The Pragmatic Programmer", author: "David Thomas, Andrew Hunt", year: 2019 }],
  ["2", { id: "2", title: "Clean Code", author: "Robert C. Martin", year: 2008 }],
  ["3", { id: "3", title: "Designing Data-Intensive Applications", author: "Martin Kleppmann", year: 2017 }],
  ["4", { id: "4", title: "The Mythical Man-Month", author: "Frederick Brooks", year: 1995 }],
  ["5", { id: "5", title: "Refactoring", author: "Martin Fowler", year: 2018 }],
]);

// Every data function waits this long before returning
const SIMULATED_DELAY_MS = 1000;

// Simulate a slow database call
function simulateLatency(): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, SIMULATED_DELAY_MS));
}

What does this code do?

  • The Book interface defines the shape of every book record: an id, title, author, and year.
  • The books Map acts as an in-memory database with five seed entries.
  • SIMULATED_DELAY_MS is set to 1000 (one second). The simulateLatency() function pauses for that duration before any data is returned, simulating a slow database.
  • Below the code you just pasted, add the three exported data functions:
// Return all books after a simulated delay
export async function getAllBooks(): Promise<Book[]> {
  await simulateLatency();
  return Array.from(books.values());
}

// Return a single book by ID after a simulated delay
export async function getBookById(id: string): Promise<Book | undefined> {
  await simulateLatency();
  return books.get(id);
}

// Update a book's fields and return the updated record
export async function updateBook(
  id: string,
  updates: Partial<Omit<Book, "id">>
): Promise<Book | undefined> {
  await simulateLatency();
  const book = books.get(id);
  if (!book) return undefined;
  const updatedBook = { ...book, ...updates };
  books.set(id, updatedBook);
  return updatedBook;
}

What do these functions do?

  • getAllBooks() returns every book in the catalog as an array.
  • getBookById() looks up a single book by its ID.
  • updateBook() merges partial updates into an existing book record. You will use this in a later step to test cache invalidation.
  • All three functions call simulateLatency() first, so every call takes a full second.
  • Save data.ts.
  • Confirm data.ts appears under the src folder in the Explorer sidebar.

Now wire the data layer into the API. The scaffold created a default src/index.ts with the Hello Hono route. You will replace its entire contents with your book catalog routes.

  • Open src/index.ts in the editor.
  • Select all the existing code with Cmd+A (macOS) or Ctrl+A (Windows).
  • Replace everything with the following code:
import { Hono } from "hono";
import { getAllBooks, getBookById } from "./data";

const app = new Hono();

// Return all books in the catalog
app.get("/api/books", async (c) => {
  const books = await getAllBooks();
  return c.json({ data: books, count: books.length });
});

// Return a single book by its ID
app.get("/api/books/:id", async (c) => {
  const id = c.req.param("id");
  const book = await getBookById(id);
  if (!book) {
    return c.json({ error: "Book not found" }, 404);
  }
  return c.json({ data: book });
});

export default app;

What does this code do?

  • The GET /api/books route calls getAllBooks() and returns the full catalog as JSON.
  • The GET /api/books/:id route uses c.req.param("id") to extract the book ID from the URL and returns a single book.
  • export default app tells Bun to serve this Hono app automatically. No extra server setup is needed.
  • Save src/index.ts.

Seeing red squiggles in the editor?

  • If VS Code shows import errors for ./data, make sure you saved data.ts in the src folder (not the project root).
  • Check that the file is named exactly data.ts with no extra spaces or capital letters.
  • help me fix import errors in my Hono project

✔️ Awesome, I've got everything!

Great. Double check that you have saved both src/data.ts and src/index.ts.

ⓧ I'd like to double check the full code

Your src/data.ts has two sections. Scroll up and confirm both are present in order:

  • The Book interface, the books Map with 5 entries, SIMULATED_DELAY_MS, and simulateLatency().
  • The three exported functions: getAllBooks(), getBookById(), and updateBook().

Your src/index.ts should look exactly like this:

import { Hono } from "hono";
import { getAllBooks, getBookById } from "./data";

const app = new Hono();

app.get("/api/books", async (c) => {
  const books = await getAllBooks();
  return c.json({ data: books, count: books.length });
});

app.get("/api/books/:id", async (c) => {
  const id = c.req.param("id");
  const book = await getBookById(id);
  if (!book) {
    return c.json({ error: "Book not found" }, 404);
  }
  return c.json({ data: book });
});

export default app;
Test the slow API

Your data layer simulates a 1-second database delay on every call. Time to feel it.

  • Start the dev server again by running:
bun run dev

Windows users and curl

In PowerShell, curl is aliased to a different command. Use curl.exe instead for all curl commands in this project.

Before you run the next command, think about this: how long do you expect the response to take?

  • Open a second terminal in VS Code by clicking the + icon in the terminal panel.
  • Fetch the full book catalog by running:
curl http://localhost:3000/api/books

The response takes about one full second before the JSON appears. That delay comes from simulateLatency() waiting 1000ms before returning data.

  • Fetch a single book by running:
curl http://localhost:3000/api/books/1

Same story. Another full second of waiting, even though you are requesting just one book.

Why is this a problem?

Every request hits the data layer and waits a full second, no matter how many times you request the same data. In a real application, this means your users wait for a database round-trip on every single page load.

Caching solves this by storing the response after the first request. Subsequent requests return the stored copy instantly, skipping the data source entirely.

Your API is running, but every request crawls. Next up, you will build an in-memory cache that drops those response times from a full second to under 5 milliseconds.

Add In-Memory Caching

Your Hono API is serving book data, but every single request crawls through a full second of simulated latency. That is painful for anyone hitting your endpoints more than once.

An in-memory cache stores responses after the first request so subsequent requests skip the data source entirely. In this step, you will build a caching middleware that sits in front of your routes and returns cached results instantly.

In this step, get ready to:
  • Create a caching middleware that stores and serves GET responses from a Map.
  • Wire the middleware into your API routes and confirm the speed difference with X-Cache headers.
Create the cache middleware that stores and serves responses from a Map

The cache-aside pattern works like a receptionist with a notebook. When a request arrives, the middleware checks its notebook (the cache) first. If the answer is there, it hands it back immediately. If not, it forwards the request to the actual handler, writes down the response, and passes it along.

  • Create a new file called src/cache.ts in your src folder.
  • Add the cache store foundation by pasting this code:
import { createMiddleware } from "hono/factory";

// Shape of each entry stored in the cache
interface CacheEntry {
  body: string;
  headers: Record<string, string>;
  timestamp: number;
}

// In-memory store keyed by request URL
const cacheStore = new Map<string, CacheEntry>();

// Wipes the entire cache store
export function clearAllCache(): void {
  cacheStore.clear();
}

What does this code do?

CacheEntry defines what gets stored for each cached response: the response body as a string, all the headers, and a timestamp recording when it was cached.

cacheStore is a Map that lives in memory for the lifetime of the server process. clearAllCache() empties the entire store. We will use it in a later step to clear stale data.

  • Save src/cache.ts.
  • In the VS Code Explorer sidebar, confirm that cache.ts now appears inside the src folder alongside index.ts and data.ts.

Don't see cache.ts in the sidebar?

Make sure you created the file inside the src folder, not in the project root. The file path should be src/cache.ts.

Help me fix my file location.

Now add the cacheMiddleware function that checks the cache on each incoming GET request. This is the reading side of the cache.

  • In src/cache.ts, add this function below clearAllCache:
// Returns cached GET responses, passes all other requests through
export function cacheMiddleware(ttlMs = 60000) {
  return createMiddleware(async (c, next) => {
    if (c.req.method !== "GET") {
      await next();
      return;
    }

    const cacheKey = c.req.url;
    const cached = cacheStore.get(cacheKey);

    if (cached) {
      const age = Date.now() - cached.timestamp;
      const headers = new Headers(cached.headers);
      headers.set("X-Cache", "HIT");
      headers.set("X-Cache-Age", `${Math.round(age / 1000)}s`);
      return new Response(cached.body, { status: 200, headers });
    }

    await next();
  });
}

What does this middleware do so far?

  • It only runs on GET requests. Non-GET requests (like POST or PUT) pass straight through to the route handler.
  • It uses the full request URL as the cache key via c.req.url, so /api/books and /api/books/1 are cached separately.
  • When a cached entry exists, it rebuilds the response with an X-Cache: HIT header and an X-Cache-Age header showing how old the entry is.
  • The ttlMs parameter defaults to 60 seconds. We have not wired up expiration logic yet. That comes in Step 4.

Right now the middleware can read from the cache, but it never writes to it. Let's wire it up first and see this gap in action.

  • Save src/cache.ts.
Apply the cache middleware to API routes and test the speed difference

The middleware exists, but your API does not know about it yet. You need to import it and register it on your routes.

  • In src/index.ts, find the existing import lines at the top of the file:
import { Hono } from "hono";
import { getAllBooks, getBookById } from "./data";
  • Add this import line directly below them:
import { cacheMiddleware, clearAllCache } from "./cache";
  • Find the line const app = new Hono(); and add this line directly below it:
app.use("/api/*", cacheMiddleware());

What do these lines do?

app.use("/api/*", cacheMiddleware()) registers the middleware on every route that starts with /api/. Every GET request to your API now passes through the cache check before reaching the route handler.

We import clearAllCache alongside cacheMiddleware because we will need it in a later step to clear stale cache entries.

  • Save src/index.ts. Bun's hot-reloading picks up the changes automatically.
  • Test the middleware by running this command:
curl -i http://localhost:3000/api/books

On Windows PowerShell?

PowerShell aliases curl to a different command. Use curl.exe instead to run the real curl binary.

You get back the full book list after the usual 1-second delay. Look at the response headers.

There is no X-Cache header at all. The middleware checked the cache, found nothing, and called the route handler. But it never stored the response, so the cache stays empty forever.

Time to add the writing side.

  • Switch back to src/cache.ts.
  • Inside the cacheMiddleware function, find the line await next(); and add this block directly below it, before the closing });:
    // Store successful responses for future requests
    if (c.res.status === 200) {
      const cloned = c.res.clone();
      const body = await cloned.text();
      const headers: Record<string, string> = {};
      cloned.headers.forEach((value, key) => {
        headers[key] = value;
      });
      cacheStore.set(cacheKey, {
        body,
        headers,
        timestamp: Date.now(),
      });
      c.res.headers.set("X-Cache", "MISS");
    }

What does this block do?

  • After await next() runs, the route handler has finished and c.res holds the response.
  • It clones the response (because a response body can only be read once), extracts the body text and headers, and stores them in the cache Map along with the current timestamp.
  • It sets X-Cache: MISS on the response so you know this result came fresh from the handler, not from the cache.
  • Save src/cache.ts.

Before you test, think about what you expect. If you run the same curl command twice, what X-Cache header should each response carry?

  • Run the request a first time:
curl -i http://localhost:3000/api/books

The response takes about 1 second and includes the header X-Cache: MISS. The middleware fetched fresh data and stored it in the cache.

  • Immediately run the same command again:
curl -i http://localhost:3000/api/books

This time the response is nearly instant and the header reads X-Cache: HIT. The middleware returned the cached copy without touching the data layer at all.

What is the X-Cache-Age header?

You also see an X-Cache-Age header showing how many seconds ago the entry was cached. If you waited a few seconds between requests, it reads something like 3s. If you ran them back-to-back, it reads 0s.

Not seeing X-Cache headers?

  • Make sure you are using curl -i (lowercase i) to display response headers. Without -i, only the response body is printed.
  • Check that app.use("/api/*", cacheMiddleware()) appears before your route definitions in src/index.ts. Middleware registered after a route does not intercept it.
  • Confirm you saved both src/cache.ts and src/index.ts. Unsaved changes are not picked up by hot-reloading.

Help me debug my missing X-Cache headers.

  • Try a different endpoint to confirm it works across routes:
curl -i http://localhost:3000/api/books/1
  • Run the same command again and confirm you see X-Cache: HIT with an instant response.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/cache.ts and src/index.ts.

ⓧ I'd like to double check the full code

Your src/cache.ts has three sections in order:

  • The import from hono/factory, the CacheEntry interface, and the cacheStore Map declaration.
  • The clearAllCache() function that clears the store.
  • The cacheMiddleware() function containing: the GET-only guard, the cache lookup with HIT return, await next(), and the response-storage block that sets X-Cache: MISS.

Scroll up and confirm each section is present in order.

Your src/index.ts should look like this:

import { Hono } from "hono";
import { getAllBooks, getBookById } from "./data";
import { cacheMiddleware, clearAllCache } from "./cache";

const app = new Hono();

app.use("/api/*", cacheMiddleware());

app.get("/api/books", async (c) => {
  const books = await getAllBooks();
  return c.json({ data: books, count: books.length });
});

app.get("/api/books/:id", async (c) => {
  const id = c.req.param("id");
  const book = await getBookById(id);
  if (!book) {
    return c.json({ error: "Book not found" }, 404);
  }
  return c.json({ data: book });
});

export default app;

Your API just went from 1-second responses to near-instant on repeat requests. But what happens when the underlying data changes? Next up, you will expose a write endpoint and discover exactly why a cache without invalidation is dangerous.

Break the Cache with Stale Data

Your in-memory cache is working. Repeated GET requests return instantly with an X-Cache: HIT header, skipping the slow data layer entirely.

But caching reads is only half the story. What happens when the underlying data changes? Right now your cache has no way to know that a book was updated, so it keeps serving the old response.

In this step, get ready to:
  • Add a PUT route for updating books.
  • Observe the stale cache problem when updates don't reach cached responses.
Review the update function in your data layer

Your src/data.ts file already includes an updateBook function from when you set up the data layer. You'll wire this into a new route so clients can update books through the API.

What does updateBook do?

  • It takes a book's id and a partial set of updates (like a new title or author).
  • It finds the existing book in the Map, merges in the new values, and saves the result back.
  • Like the other data functions, it includes the simulated 1-second delay.
Add a PUT route for updating books

To use updateBook in your routes, you first need to import it.

  • In src/index.ts, find the import line at the top of the file that pulls from ./data:
import { getAllBooks, getBookById } from "./data";
  • Add updateBook to the import so the line looks like this:
import { getAllBooks, getBookById, updateBook } from "./data";
  • Add the PUT route to src/index.ts by copying the following code below your last app.get("/api/books/:id") route:
app.put("/api/books/:id", async (c) => {
  // Extract the book ID from the URL
  const id = c.req.param("id");
  // Parse the JSON body for the fields to update
  const updates = await c.req.json();
  const book = await updateBook(id, updates);
  if (!book) {
    return c.json({ error: "Book not found" }, 404);
  }
  return c.json({ data: book, message: "Book updated" });
});

What does this route do?

  • app.put registers a handler for HTTP PUT requests, which is the standard method for updating a resource.
  • c.req.json() parses the incoming request body as JSON so you can read the fields the client wants to change.
  • Notice this route does NOT touch the cache at all. It updates the book in the data layer and returns the result. The cache is completely unaware that anything changed.
  • Save src/index.ts.

Bun's hot reload picks up the change automatically. Your cache is now empty because the server restarted.

Seeing a syntax error in the terminal?

  • Make sure your new app.put block is placed AFTER the closing }); of the app.get("/api/books/:id") route, not inside it.
  • Check that updateBook is included in the import from ./data at the top of the file.

Help me debug a syntax error in my Hono PUT route

Now let's test. First, you need to populate the cache with a GET request so there's something stale to find.

  • In your second terminal, cache book 1 by running this command:
curl http://localhost:3000/api/books/1

This takes about 1 second (a cache MISS). You should see book 1 with the title The Pragmatic Programmer in the response. The cache now stores this response.

  • Update book 1's title by running this PUT request:
curl -X PUT http://localhost:3000/api/books/1 -H "Content-Type: application/json" -d '{"title":"Updated Title"}'

Using PowerShell on Windows?

Use curl.exe instead of curl. PowerShell aliases curl to a different command. Also replace the single quotes around the -d payload with escaped double quotes.

The PUT response confirms the update worked. You should see Updated Title in the response body alongside the message Book updated.

The data layer now holds the new title. Before you fetch the book again, what do you think the next GET request will return? The updated title, or the original one?

  • Fetch book 1 again by running:
curl -i http://localhost:3000/api/books/1

The response still shows The Pragmatic Programmer as the title, even though you just changed it. The X-Cache: HIT header confirms why. The cache happily served its stored copy without checking whether the data changed.

Why is the cache serving old data?

Your cache middleware only knows about GET requests. When the PUT request updated the book, the cache had no idea. It still holds the response from the first GET and keeps serving it.

This is the classic stale data problem. A cache that speeds up reads but ignores writes is worse than no cache at all, because it silently returns incorrect information. The next step fixes this.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/index.ts.

ⓧ I'd like to double check the full code

Only src/index.ts changed in this step. Scroll up and confirm it has these pieces in order:

  • Import getAllBooks, getBookById, and updateBook from ./data.
  • Import cacheMiddleware and clearAllCache from ./cache.
  • app.use("/api/*", cacheMiddleware()) applying cache to all API routes.
  • A GET route for /api/books returning all books.
  • A GET route for /api/books/:id returning a single book.
  • A PUT route for /api/books/:id that calls updateBook and returns the updated book with no cache logic.
  • export default app at the bottom.

src/data.ts and src/cache.ts are unchanged from the previous step.

You've exposed the biggest risk of caching: stale data. Your cache speeds up reads but silently lies when the data changes. Next up, you'll fix this with TTL expiration and cache invalidation so updates are reflected immediately.

Fix It with TTL and Cache Invalidation

You just saw the classic caching trap in action. Your cache happily served outdated book titles even after the data layer had already updated them.

Two strategies fix this. TTL (Time to Live) automatically expires old entries after a set period. Cache invalidation immediately clears the cache when data changes. In this step, you'll add both.

In this step, get ready to:
  • Upgrade the cache middleware with TTL-based expiration.
  • Add cache invalidation to the PUT route so writes clear the cache.
Upgrade the cache middleware with TTL-based expiration

Your cache middleware already accepts a ttlMs parameter, but it never actually checks whether an entry has expired. Every cached response gets returned regardless of how old it is.

  • In src/cache.ts, find the if (cached) block inside the cacheMiddleware function.
  • Find this code:
    if (cached) {
      const age = Date.now() - cached.timestamp;
      const headers = new Headers(cached.headers);
      headers.set("X-Cache", "HIT");
      headers.set("X-Cache-Age", `${Math.round(age / 1000)}s`);
      return new Response(cached.body, { status: 200, headers });
    }
  • Replace the entire if (cached) block with this updated version:
    if (cached) {
      const age = Date.now() - cached.timestamp;
      // Only serve from cache if the entry hasn't expired
      if (age < ttlMs) {
        const headers = new Headers(cached.headers);
        headers.set("X-Cache", "HIT");
        headers.set("X-Cache-Age", `${Math.round(age / 1000)}s`);
        return new Response(cached.body, { status: 200, headers });
      }
      // Entry is stale, remove it
      cacheStore.delete(cacheKey);
    }

What changed?

  • if (age < ttlMs) checks whether the cached entry is still within its TTL window before returning it.
  • If the entry is older than the TTL, cacheStore.delete(cacheKey) removes it and the request falls through to the data source.
  • Now switch to src/index.ts and find this line:
app.use("/api/*", cacheMiddleware());
  • Replace cacheMiddleware() with cacheMiddleware(30000) to set a 30-second TTL:
app.use("/api/*", cacheMiddleware(30000));

Why 30 seconds?

A 30-second TTL means cached responses automatically expire after 30 seconds. This ensures your API never serves data more than half a minute old, even without explicit invalidation.

  • Save both src/cache.ts and src/index.ts.
  • Confirm the cache still works by running this command:
curl -i http://localhost:3000/api/books

The first response shows X-Cache: MISS and takes about 1 second. Run the same command again immediately:

curl -i http://localhost:3000/api/books

The second response shows X-Cache: HIT and returns instantly. Since you're requesting within 30 seconds, the cache still serves the entry.

Not seeing X-Cache headers?

  • Make sure you saved both files. Bun's hot reload picks up changes on save.
  • Check that the if (age < ttlMs) block wraps around the new Response(...) return, not below it.
  • Make sure the closing } for the if (age < ttlMs) block comes before cacheStore.delete(cacheKey).

Why aren't my X-Cache headers showing up after adding TTL?

Add cache invalidation to the PUT route

TTL handles gradual expiration, but when a user updates a book, they expect to see the change immediately. Cache invalidation clears the cache on writes so the next read fetches fresh data from the source.

  • In src/index.ts, find the end of the app.put handler. Find this line:
  return c.json({ data: book, message: "Book updated" });
  • Add a clearAllCache() call above the return and update the message so the end of your PUT handler looks like this:
  // Wipe the cache so the next GET fetches fresh data
  clearAllCache();
  return c.json({ data: book, message: "Book updated, cache invalidated" });

What does this do?

clearAllCache() wipes every entry from the cache Map. This is a simple but effective strategy called "clear-all" invalidation.

The updated message confirms to API consumers that the cache was cleared alongside the data update.

  • Save src/index.ts.

Before you test, think about what you expect to happen. When you update a book title with PUT and then GET the same book, will the cache serve the old title or the new one this time?

  • Update book 1's title by running this command:
curl -X PUT http://localhost:3000/api/books/1 -H "Content-Type: application/json" -d '{"title":"Updated Title"}'
  • Now fetch book 1 to see if the cache returns the updated data:
curl -i http://localhost:3000/api/books/1

The response shows the UPDATED title (Updated Title) and the X-Cache header shows MISS. The cache was cleared by the PUT request, so this GET fetched fresh data from the data layer.

Compare this to Step 3

In Step 3, the exact same PUT-then-GET sequence returned the OLD title because the cache had no way to know the data changed. Now, clearAllCache() wipes the stale entry on every write, and the TTL acts as a safety net for anything that slips through.

Still seeing the old title?

  • Make sure clearAllCache() is called BEFORE the return c.json(...) line in the PUT handler, not after it.
  • Confirm you're importing clearAllCache from ./cache at the top of src/index.ts. This import was added in an earlier step.
  • Check that you saved the file. Bun's hot reload only applies after a save.

Why is my cache still serving stale data after I added clearAllCache?

✔️ Awesome, I've got everything!

Great. Double check you've saved both src/cache.ts and src/index.ts.

ⓧ I'd like to double check the full code

Scroll up and confirm these changes are in place:

  • src/cache.ts: The if (cached) block now wraps the HIT response inside if (age < ttlMs) and calls cacheStore.delete(cacheKey) when the entry is expired.
  • src/index.ts: The middleware is applied with cacheMiddleware(30000) instead of cacheMiddleware().
  • src/index.ts: The PUT handler calls clearAllCache() before the return and the message reads "Book updated, cache invalidated".

Your cache now handles both reads and writes correctly. Next up, you'll add a second layer of caching at the HTTP level so clients that already have the data skip downloading it entirely.

Add HTTP-Level Caching with ETags

Your in-memory cache solved the server-side speed problem. Repeated GET requests now return instantly from the Map store instead of waiting a full second for the data layer. But there is still a gap: even when the data has not changed, the server re-sends the entire JSON response body every time.

ETag headers solve this at the HTTP level. The server generates a fingerprint of each response body, and clients that already have that fingerprint receive a tiny 304 Not Modified response instead of the full payload.

In this step, get ready to:
  • Add Hono's built-in ETag middleware to the API routes.
  • Test conditional requests with curl using the If-None-Match header.
Add Hono's built-in etag middleware to the API routes

Hono ships with a built-in etag() middleware that handles all of this automatically. You just need to register it on your API routes.

  • Switch back to src/index.ts in VS Code.
  • Find the import lines at the top of the file.
  • Add the etag import and middleware registration so the top of your file looks like this:
import { Hono } from "hono";
// Hono's built-in ETag middleware for HTTP-level caching
import { etag } from "hono/etag";
import { getAllBooks, getBookById, updateBook } from "./data";
import { cacheMiddleware, clearAllCache } from "./cache";

const app = new Hono();

// etag() must come before cacheMiddleware so it wraps cached responses too
app.use("/api/*", etag());
app.use("/api/*", cacheMiddleware(30000));

What does this code do?

  • import { etag } from "hono/etag" pulls in Hono's built-in ETag middleware. No extra packages needed.
  • app.use("/api/*", etag()) generates a hash fingerprint of every response body and attaches it as an ETag header. When a client sends that fingerprint back via If-None-Match, the middleware returns 304 Not Modified with no body instead of the full payload.

Why does etag() go before cacheMiddleware()?

Middleware in Hono runs in registration order. By placing etag() first, it wraps around cacheMiddleware(). This means ETag fingerprints are generated on every response, whether the data came fresh from the data layer or was served from the in-memory cache.

  • Save src/index.ts.
  • Confirm your dev server is still running in the terminal from earlier. If it stopped, start it again by running:
bun run dev
  • Test that ETags are showing up by running this in a separate terminal:
curl -i http://localhost:3000/api/books

Look at the response headers. You should see a new ETag header with a value like W/"..." alongside your familiar X-Cache header.

Don't see an ETag header?

  • Make sure app.use("/api/*", etag()) appears before the cacheMiddleware line. Middleware order matters.
  • Check that the import line reads import { etag } from "hono/etag" exactly.
  • Confirm you saved src/index.ts after making changes. Bun's hot reload picks up saves automatically.
  • Why isn't my ETag header showing up after adding etag() middleware?
Test conditional requests with curl

The ETag header is showing up, but the real payoff is what happens when a client sends that fingerprint back. The server can skip the entire response body if nothing changed.

  • Run the same request again and this time copy the full ETag value from the response headers (including the W/"..." wrapper):
curl -i http://localhost:3000/api/books

Before you run the next command, take a guess. If you send that exact ETag value back to the server, do you think it will send the full book list again, or something different?

  • Send the ETag value back to the server using the If-None-Match header by running this command, replacing the placeholder with your actual ETag value:
curl -i -H "If-None-Match: [[ETAG_VALUE="your ETag value from the previous response"]]" http://localhost:3000/api/books

The response status is 304 Not Modified with no body. The server recognized the fingerprint and said "you already have the latest data, no need to re-download."

What just happened?

You now have two layers of caching working together. Your in-memory cache (the Map store) speeds up the server so it does not hit the data layer on every request. The ETag middleware saves bandwidth on the client side so clients that already have the data do not download it again.

The X-Cache header tells you whether the server-side cache was used. The 304 status tells you the client-side cache was used. They solve different problems at different layers.

Still getting a 200 response with a full body?

  • Make sure you copied the entire ETag value exactly, including the W/" prefix and the closing quote.
  • Check that the header name is If-None-Match (with hyphens, not underscores or spaces).
  • If you updated a book with PUT between your two requests, the data changed and the ETag changed with it. Run the first curl -i command again to get a fresh ETag, then retry.
  • Why am I not getting a 304 response when I send the If-None-Match header?

✔️ Awesome, I've got everything!

Great. Double check you have saved src/index.ts.

ⓧ I'd like to double check the full code

The only file you changed in this step is src/index.ts. Scroll up and confirm these two additions are in place:

  • The import import { etag } from "hono/etag" is present near the top of the file, after the Hono import.
  • The line app.use("/api/*", etag()) appears before the app.use("/api/*", cacheMiddleware(30000)) line.
  • All existing routes (GET /api/books, GET /api/books/:id, PUT /api/books/:id) and the export default app at the bottom are unchanged.

Your API now has two distinct caching layers working together. The in-memory cache makes the server fast, and ETags save your clients from downloading data they already have. Up next in the Secret Mission, you will build a stats endpoint that tracks exactly how often each layer kicks in.

Secret mission

Build a Cache Stats Dashboard

Your cache is working, but how well is it actually performing? Build a /cache/stats endpoint that tracks hits, misses, and invalidations in real time. You will need to track state across middleware invocations, compute derived metrics like hit rate percentage, and expose everything through a new route.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing cloud costs or paid services to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The my-app project folder on your Desktop (contains src/index.ts, src/data.ts, and src/cache.ts).
  • The Bun dev server process (started with bun run dev).
  • Bun runtime installed on your machine.

Keep everything running

No action needed. Choose this if you are still actively building on the project or want to experiment with the caching layers further.

  • Your Bun dev server keeps running in the terminal. You can stop it anytime with Ctrl+C and restart it with bun run dev.
  • Your my-app folder stays on your Desktop.
  • Bun stays installed on your machine for future projects.

Pause - I'll come back to this later

Shut down the running process to free up system resources. Your project files stay on disk so you can pick up where you left off.

  • Switch to the terminal running your dev server and press Ctrl+C to stop it.
  • Your my-app folder and all code files remain on your Desktop.
  • When you are ready to resume, open a terminal, navigate to the my-app folder, and run:
bun run dev

Your API will be back at http://localhost:3000 with all your caching middleware intact.

Delete - I don't want to use this again

Remove all project files and optionally uninstall Bun from your machine.

  • Switch to the terminal running your dev server and press Ctrl+C to stop it.
  • Delete the project folder by running:

macOS

cd ~/Desktop
rm -rf my-app

Windows

cd ~\Desktop
rmdir /s /q my-app
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see my-app in the output.

If you also want to remove Bun from your machine:

macOS

rm -rf ~/.bun

Windows

powershell -c ~\.bun\uninstall.ps1

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully layered caching system for a Hono API running on Bun. Your book catalog went from painfully slow one-second responses to near-instant cache hits, automatic stale-data cleanup, and bandwidth-saving 304 Not Modified responses.

You've learned how to:

  • Build a custom in-memory caching middleware using the cache-aside pattern with a Map store, dropping response times from ~1000ms to under 5ms and using X-Cache headers to observe cache behavior in real time.
  • Experience the stale data problem firsthand and fix it with TTL expiration and cache invalidation, so writes immediately clear outdated entries and the next read fetches fresh data.
  • Add HTTP-level caching with Hono's built-in ETag middleware so clients that already have the data receive a tiny 304 Not Modified response instead of the full payload.
  • Secret Mission: Build a /cache/stats endpoint that tracks hit rate, miss rate, and invalidation count in real time.

Ready to quiz yourself?