Skip to content

perf(state): skip response body on HEAD requests#8348

Open
Nayte91 wants to merge 1 commit into
api-platform:mainfrom
Nayte91:perf/head-skip-body
Open

perf(state): skip response body on HEAD requests#8348
Nayte91 wants to merge 1 commit into
api-platform:mainfrom
Nayte91:perf/head-skip-body

Conversation

@Nayte91

@Nayte91 Nayte91 commented Jun 23, 2026

Copy link
Copy Markdown
Q A
Branch? main
Tickets follow-up to #7856 (limitation #1), #7137
License MIT
Doc PR no but I can do this after

This is the follow-up to the HEAD remark I left in #7856 (limitation #1): today a HEAD is processed exactly like a GET — the collection is fully read, hydrated and serialized, and Symfony only strips the body right before sending. So the database does all the work for nothing.

This PR makes a HEAD request skip body construction entirely: the (lazy) Doctrine collection is never iterated → no row SELECT. The response still carries its headers, just no body — which is what HEAD is for.

One single commit, decoupled from the Content-Range topic on purpose: it stands on its own even if #7856 isn't merged.

RFC 9110 section followed

§9.3.2 HEAD"The HEAD method is identical to GET except that the server MUST NOT send content in the response." The spec explicitly allows a server to omit header fields whose value is only determined while generating the content, and states this is "preferable to generating and discarding the content for a HEAD request, since HEAD is usually requested for the sake of efficiency." That's exactly what we do.

Summed up design considerations

  • where does API-Platform actually know the request verb? (spoiler: only $request — a HEAD is matched to the GET operation, so $operation->getMethod() returns GET)
  • where is the response body actually built, and where is the collection iterated?
  • where do we cut it without losing the headers?

Main logic implemented

A HEAD short-circuit at each of the 3 points where a response body is born — right after original_data is captured (so headers still compute) and before any iteration:

  • SerializeProcessor — standard serialization (all formats) → forwards an empty body.
  • Hydra\State\JsonStreamerProcessor & Serializer\State\JsonStreamerProcessor — the jsonStream: true paths, which build the body independently of SerializeProcessor (and, in listener mode, via their own kernel.view listener — hence their own guard).

Detection is $request->isMethod('HEAD'): the operation can't tell us, by construction.

Tests

  • HeadRequestTest (functional) — backed by a spy paginator whose getIterator()/count() throw, so a HEAD returning 200 proves the collection was never iterated (a naive "200 + empty body" test would pass even if the SELECT ran). Covers: collection HEAD (no iteration), GET (does iterate → sanity check, keeps the test non-vacuous), OPTIONS (unaffected), and a jsonStream: true resource.
  • SerializeProcessorTest (unit) — asserts the serializer is never called on HEAD.

Limitations & concerns

  1. Content-Length is no longer set on HEAD (we don't build the body to measure it). Allowed by RFC 9110 §9.3.2, but a small observable change for strict clients.
  2. HTTP-cache resources (_resources, surrogate keys) aren't populated on HEAD since serialization is skipped → cache-tag headers may differ between GET and HEAD. Fine IMO (a HEAD has no rows), but worth flagging.
  3. A security expression on a collection that iterates object still triggers the SELECT — it runs in the provider, before the processors, so this optimization can't prevent it. The common case (is_granted('ROLE_X')) is unaffected.
  4. HEAD on an item saves nothing (the single row is already fetched by the provider); the body is still skipped, but there's no DB win there, for now. The whole point of this PR is collections.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant