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
conditionalResponsereceivesc.request— the original Request object from the Hectoday HTTP context. TheIf-None-Matchheader is on the incoming request, not something we set.
Combining Cache-Control and ETags
They work together:
max-age=60— Browser uses cached response for 60 seconds without contacting the server.- After 60 seconds, browser sends
If-None-Matchwith the ETag. - 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?