Problem
Users currently have to register HEAD routes manually for every GET-able route. A HEAD /foo request to an app with only app.get("/foo", ...) returns 404, because route matching in ~findRoute asks rou3 for an exact method match (src/h3.ts):
override "~findRoute"(_event: H3Event): MatchedRoute<H3Route> | void {
return findRoute(this["~rou3"], _event.req.method, _event.url.pathname);
}
Per RFC 9110, HEAD is identical to GET except the response has no body, and servers SHOULD support it for any GET-able resource. Express supports this implicitly, and Fastify enables it by default (exposeHeadRoutes: true). h3 users coming from those frameworks hit surprise 404s (e.g. from load balancer health checks, link checkers, CDNs probing resources).
Notably, half the feature already exists in core: toResponse → prepareResponse already strips response bodies for HEAD requests via nullBody() (src/response.ts). Only the routing half is missing. There is also internal precedent for caring about HEAD: serveStatic special-cases it, and isMethod/assertMethod have an allowHead parameter.
Proposal
When a HEAD request does not match any explicit HEAD (or all()) route, fall back to the matching GET route. The request method is not rewritten — event.req.method stays "HEAD", so the existing nullBody() logic strips the body automatically while the handler produces correct headers.
- Zero cost on non-HEAD traffic (the extra lookup only runs on a HEAD route miss).
- Explicit
app.head() routes still take precedence (fallback only fires on a miss), preserving the escape hatch for cheap HEAD handlers that skip body computation.
- Route-level middleware attached to the GET route (auth, etc.) runs as expected.
- Default-on, no config flag (matching Express semantics and keeping
H3Config minimal). A flag can be added later if someone needs strict 405-style behavior.
Implementation steps
1. Routing fallback (src/h3.ts)
Change the ~findRoute override in the H3 class:
override "~findRoute"(_event: H3Event): MatchedRoute<H3Route> | void {
const match = findRoute(this["~rou3"], _event.req.method, _event.url.pathname);
if (match === undefined && _event.req.method === "HEAD") {
return findRoute(this["~rou3"], "GET", _event.url.pathname);
}
return match;
}
Note: event.context.matchedRoute will reference the GET route for a fallback HEAD request — this is intended.
2. Strip body on the raw-Response fast path (src/response.ts)
prepareResponse already applies nullBody() for non-Response return values. But when a handler returns a raw Response and no prepared headers need merging, the fast path returns it with the body intact:
if (!preparedHeaders || nested || !val.ok) {
return val; // Fast path: no headers to merge
}
Runtimes (Node, Deno, Bun, workerd) discard HEAD bodies at the server layer anyway, but for self-consistency add a HEAD check on this path: if event.req.method === "HEAD" and val.body is not null, return a body-less FastResponse with the same status/statusText/headers. Keep the check cheap (method comparison first) to avoid affecting the hot path.
3. Tests (test/router.test.ts, using describeMatrix)
Add a describe("HEAD fallback") block covering:
HEAD to a route registered with .get() → status 200, empty body (await res.text() is ""), and headers produced by the handler are preserved (e.g. content-type: application/json... for an object return).
- Explicit
.head() route takes precedence over the GET fallback (register both with distinguishable headers, assert the HEAD handler ran).
HEAD to a path with no GET route either → still 404.
- Handler returning a raw
Response on a HEAD request → empty body, headers/status preserved (covers step 2).
HEAD matching an all() route still works (no behavior change; guards against regressions in the fallback ordering).
- Route-level middleware on the GET route runs for the fallback HEAD request.
The existing test "Not matching route method" (HEAD /preemptive/404) remains valid — that path has no GET route, so the fallback still yields 404. Confirm it passes unchanged.
Follow the bug-fix workflow from AGENTS.md: write tests first, confirm the new ones fail before the code change, then implement.
4. Docs
Update docs/1.guide/1.basics/2.routing.md: add a short "HEAD requests" section stating that HEAD requests automatically match GET routes with the response body omitted, and that registering an explicit head() route overrides this for handlers that want to skip body computation.
5. Verify
pnpm vitest run test/router.test.ts
pnpm test # full suite: lint + typecheck + coverage
pnpm bench:node # confirm no regression on GET hot path
Known trade-offs (accepted)
- The GET handler computes the full body which is then discarded. This is the correct default (accurate headers matter more); explicit
head() registration remains the optimization path.
- Method-scoped global middleware (
app.use(fn, { method: "GET" })) will not run for fallback HEAD requests since event.req.method stays "HEAD". Out of scope for this issue — can be revisited if it comes up in practice.
Problem
Users currently have to register HEAD routes manually for every GET-able route. A
HEAD /foorequest to an app with onlyapp.get("/foo", ...)returns404, because route matching in~findRouteasks rou3 for an exact method match (src/h3.ts):Per RFC 9110, HEAD is identical to GET except the response has no body, and servers SHOULD support it for any GET-able resource. Express supports this implicitly, and Fastify enables it by default (
exposeHeadRoutes: true). h3 users coming from those frameworks hit surprise 404s (e.g. from load balancer health checks, link checkers, CDNs probing resources).Notably, half the feature already exists in core:
toResponse→prepareResponsealready strips response bodies for HEAD requests vianullBody()(src/response.ts). Only the routing half is missing. There is also internal precedent for caring about HEAD:serveStaticspecial-cases it, andisMethod/assertMethodhave anallowHeadparameter.Proposal
When a HEAD request does not match any explicit HEAD (or
all()) route, fall back to the matching GET route. The request method is not rewritten —event.req.methodstays"HEAD", so the existingnullBody()logic strips the body automatically while the handler produces correct headers.app.head()routes still take precedence (fallback only fires on a miss), preserving the escape hatch for cheap HEAD handlers that skip body computation.H3Configminimal). A flag can be added later if someone needs strict 405-style behavior.Implementation steps
1. Routing fallback (
src/h3.ts)Change the
~findRouteoverride in theH3class:Note:
event.context.matchedRoutewill reference the GET route for a fallback HEAD request — this is intended.2. Strip body on the raw-
Responsefast path (src/response.ts)prepareResponsealready appliesnullBody()for non-Responsereturn values. But when a handler returns a rawResponseand no prepared headers need merging, the fast path returns it with the body intact:Runtimes (Node, Deno, Bun, workerd) discard HEAD bodies at the server layer anyway, but for self-consistency add a HEAD check on this path: if
event.req.method === "HEAD"andval.bodyis not null, return a body-lessFastResponsewith the same status/statusText/headers. Keep the check cheap (method comparison first) to avoid affecting the hot path.3. Tests (
test/router.test.ts, usingdescribeMatrix)Add a
describe("HEAD fallback")block covering:HEADto a route registered with.get()→ status 200, empty body (await res.text()is""), and headers produced by the handler are preserved (e.g.content-type: application/json...for an object return)..head()route takes precedence over the GET fallback (register both with distinguishable headers, assert the HEAD handler ran).HEADto a path with no GET route either → still 404.Responseon a HEAD request → empty body, headers/status preserved (covers step 2).HEADmatching anall()route still works (no behavior change; guards against regressions in the fallback ordering).The existing test
"Not matching route method"(HEAD /preemptive/404) remains valid — that path has no GET route, so the fallback still yields 404. Confirm it passes unchanged.Follow the bug-fix workflow from AGENTS.md: write tests first, confirm the new ones fail before the code change, then implement.
4. Docs
Update
docs/1.guide/1.basics/2.routing.md: add a short "HEAD requests" section stating that HEAD requests automatically match GET routes with the response body omitted, and that registering an explicithead()route overrides this for handlers that want to skip body computation.5. Verify
Known trade-offs (accepted)
head()registration remains the optimization path.app.use(fn, { method: "GET" })) will not run for fallback HEAD requests sinceevent.req.methodstays"HEAD". Out of scope for this issue — can be revisited if it comes up in practice.