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

ETags and Conditional Requests

The problem with max-age alone

Cache-Control: max-age=60 works well, but after 60 seconds the browser must download the full response again — even if the data has not changed. For a large response (a list of 1,000 books), this wastes bandwidth.

An ETag is a fingerprint of the response content. The browser sends the fingerprint on subsequent requests. If the content has not changed (same fingerprint), the server responds with 304 Not Modified — no body, no bandwidth.

How ETags work

First request: The server returns the response with an ETag header:

HTTP/1.1 200 OK
ETag: "abc123"
Content-Type: application/json

[{"id":"book-1","title":"The Left Hand of Darkness"}, ...]

Second request: The browser sends the ETag back in an If-None-Match header:

GET /books HTTP/1.1
If-None-Match: "abc123"

Server checks: If the data has not changed (same ETag), return 304 with no body. If it has changed (different ETag), return 200 with the new data and new ETag.

HTTP/1.1 304 Not Modified

The browser uses its cached copy. No response body was sent.

Generating ETags

An ETag should change when the content changes. Common approaches:

Hash the response body:

import { createHash } from "node:crypto";

function generateETag(data: unknown): string {
  const json = JSON.stringify(data);
  const hash = createHash("md5").update(json).digest("hex");
  return `"${hash}"`;
}

Use a timestamp or version:

// If you track updated_at, use the latest one
function generateETag(updatedAt: string): string {
  return `"${updatedAt}"`;
}

The hash approach works for any data but requires computing the hash on every request. The timestamp approach is faster but requires a reliable “last modified” value.

Implementing conditional responses

// src/etag.ts
import { createHash } from "node:crypto";

export function generateETag(data: unknown): string {
  const json = JSON.stringify(data);
  return `"${createHash("md5").update(json).digest("hex")}"`;
}

export function conditionalResponse(
  request: Request,
  data: unknown,
  cacheControl: string = "public, max-age=0",
): Response {
  const etag = generateETag(data);
  const ifNoneMatch = request.headers.get("if-none-match");

  if (ifNoneMatch === etag) {
    // Data has not changed — return 304 with no body
    return new Response(null, {
      status: 304,
      headers: { ETag: etag, "Cache-Control": cacheControl },
    });
  }

  // Data has changed (or first request) — return full response
  return new Response(JSON.stringify(data), {
    headers: {
      "Content-Type": "application/json",
      ETag: etag,
      "Cache-Control": cacheControl,
    },
  });
}
route.get("/books", {
  resolve: (c) => {
    const books = db.prepare("SELECT ...").all();
    return conditionalResponse(c.request, books, "public, max-age=60");
  },
});

The first request returns 200 with the ETag. Subsequent requests within 60 seconds skip the server entirely (browser cache via max-age). After 60 seconds, the browser sends the ETag. If the data is unchanged, the server returns 304 (no body). If changed, 200 with new data.

[!NOTE] Notice that conditionalResponse receives c.request — the original Request object from the Hectoday HTTP context. The If-None-Match header is on the incoming request, not something we set.

Combining Cache-Control and ETags

They work together:

  1. max-age=60 — Browser uses cached response for 60 seconds without contacting the server.
  2. After 60 seconds, browser sends If-None-Match with the ETag.
  3. Server checks: data unchanged → 304 (fast, no body). Data changed → 200 with new data.

This gives you the best of both: no requests at all during the cache window, and minimal bandwidth after it expires.

Weak ETags

A weak ETag indicates semantic equivalence (the content is logically the same) rather than byte-for-byte identity:

ETag: W/"abc123"    → weak (content is logically equivalent)
ETag: "abc123"      → strong (content is byte-identical)

Weak ETags are useful when the response format might vary (different whitespace, different field ordering) but the data is the same. For JSON APIs, strong ETags (hashing the exact response) are usually fine.

Exercises

Exercise 1: Add ETags to the /books endpoint. Make a request. Note the ETag. Make a second request with If-None-Match. Verify you get 304.

Exercise 2: Add a new book. Make the same request. Verify the ETag has changed and you get 200 with the new data.

Exercise 3: Combine ETags with max-age=30. Verify the browser skips the server for 30 seconds, then revalidates with the ETag.

Why does a 304 response save bandwidth compared to a 200?

← Cache-Control Headers Stale-While-Revalidate →

© 2026 hectoday. All rights reserved.