ETagsMiddleware · Hono

Tag every cat response with etag() so a client that already has it gets 304 and no body, watch the tag change when the cat changes, and pair it with Cache-Control.

What you will learn

Read the theory for ETags

All Middleware lessons

All Hono courses

loading types…

What you'll learn

  • Add etag() on a prefix and read the tag it computes from the body
  • Send If-None-Match back and get 304 with no body and only the retained headers
  • See a change to the resource change its tag, and pair the tag with Cache-Control: max-age

ETags

A client that shows a cat's page asks for /cats/1 every time the page opens, and the API serialises and sends the same cat every time. The bytes are small; the habit is not. HTTP has a two-line protocol for "has this changed?": the server tags each response with an identifier of its content, the client sends the tag back on its next request, and the server answers 304 Not Modified with no body when the tag still matches. Hono's etag middleware does the tagging and the comparing, and this lesson adds it to the cats API together with the caching header it works best beside.

The exchange

GET /cats/1
→ 200, ETag: "5096e5…", body

GET /cats/1, If-None-Match: "5096e5…"
→ 304, ETag: "5096e5…", no body

PATCH /cats/1 …

GET /cats/1, If-None-Match: "5096e5…"
→ 200, ETag: "a1d3…", body

The tag is opaque to the client; it only has to send it back. If-None-Match may also carry several tags, or *, meaning "if anything exists".

etag()

import { etag } from 'hono/etag';

app.use('/etag/*', etag());
app.get('/etag/abc', (c) => c.text('Hono is hot'));

For a successful GET (or HEAD) the middleware hashes the response body with SHA-1 and sets ETag to the hex digest in quotes, "5096e579189b967623bedf1a4ea178bf19e5086d". When the request's If-None-Match matches, it replaces the response with a 304 that has no body and keeps only the headers a client needs to interpret it: ETag, Cache-Control, Content-Location, Date, Expires and Vary; the retainedHeaders option changes that list. A handler that sets its own ETag keeps it, and the middleware only compares. weak: true emits W/"…", a weak tag, for responses that are equivalent rather than identical byte for byte.

Note what the middleware cannot save. The handler still runs and still builds the body, since the hash is computed from it; what a 304 saves is the transfer, not the work. An API whose responses are expensive to compute needs a cache in front of the handler, which is a different tool.

Cache-Control beside it

An ETag alone makes every request a round trip that may end in 304. Cache-Control: max-age=60 tells the client it may reuse the response for a minute without asking at all, and after that minute the If-None-Match round trip resumes. The two headers answer two questions, "how long may I not ask?" and "when I do ask, has it changed?", and a response worth caching usually carries both. Because Cache-Control is one of the retained headers, the 304 carries it too, restarting the client's minute.

Where it sits

Above the routes it should tag, on a prefix. The middleware acts after next(), on the response, so its position relative to guards does not matter, and it ignores errors and other statuses: a 404 gets no tag.

Your task

The cat routes answer full bodies every time.

  1. Tag every response under /cats with etag(), so a request whose If-None-Match matches is answered 304.
  2. GET /cats/:id sets Cache-Control: max-age=60.

Send GET /cats/1 from the request bar, copy the ETag from the response headers into an If-None-Match request header, and send again; then PATCH /cats/1 and send the conditional request once more.

When it fails

  • No etag header on GET /cats/1: the middleware is on the wrong path, or registered below the route.
  • The conditional request answers 200 with the same tag: If-None-Match was sent without the quotes. The tag includes them.
  • The 304 lacks cache-control: the handler never set it. Only headers that exist can be retained.
  • After the PATCH the old tag still gets 304: the handler answered a copy made before the change, or the PATCH did not modify the stored cat.

Remember

  • etag() tags successful GET responses with a quoted SHA-1 of the body and answers a matching If-None-Match with 304 and no body.
  • The 304 keeps ETag, Cache-Control, Content-Location, Date, Expires and Vary; retainedHeaders changes the list.
  • A 304 saves the transfer, not the handler's work.
  • Pair it with Cache-Control: max-age for the "do not even ask" period.
Stuck? Show a hint

app.use('/cats/*', etag()) tags every successful GET under /cats. The handler sets c.header('Cache-Control', 'max-age=60') before c.json(); the middleware keeps that header on the 304. Nothing about the PATCH changes.