Skip to content

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

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

perf(state): skip response body on HEAD requests#8348
Nayte91 wants to merge 2 commits 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 on the database — and this is by design: there is no DB win to be had there at all. An item HEAD must still return the correct 200/404/403, which requires the read. Existence is only knowable by querying, and an object-level security expression (e.g. is_granted('VIEW', object)) needs the hydrated entity — it is evaluated in the provider, before any processor, and on HEAD exactly like GET. So even degrading the fetch to a bare EXISTS would not help as soon as security (or validation, or serialization) touches the object. And unlike a collection — whose paginator is lazy, so the row SELECT is deferred to body-building and thus skippable — an item is fetched eagerly, upstream of the processors. The body is still correctly empty; there is simply nothing to gain. This PR is about collections.

@soyuka soyuka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is quite safe, we need a note in the changelog for that cache key change though.

Comment thread tests/Functional/HeadRequestTest.php Outdated
'headers' => ['Accept' => 'application/ld+json'],
]);

$this->assertResponseStatusCodeSame(500);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throw a proper HttpException in the test so that you can use a fixed status code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, SpyPaginator now raises a very specific HttpException(Response::HTTP_I_AM_A_TEAPOT) that this test catches.

// @see ApiPlatform\State\Processor\RespondProcessor
$context['original_data'] = $data;

if ($request->isMethod('HEAD')) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will make skip the cache keys (below _resources) but if we want them we need to serialize...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should now be handled by the second commit (the opt-out flag) — what do you think?

@soyuka

soyuka commented Jun 24, 2026

Copy link
Copy Markdown
Member

I'm concerned about the Cache-Tags issue, we need to:

add this at the top of changelog:

HEAD requests no longer build the response body: the collection is never
  iterated (no row SELECT) and serialization is skipped. Two observable changes:

  - `Content-Length` is no longer set on HEAD (RFC 9110 §9.3.2 permits this).
  - Cache-tag/xkey headers are not emitted on HEAD. Previously HEAD carried the
    same tags as GET, so cached HEAD responses were tag-purgeable; now they
    invalidate by TTL only. Body-less, so impact is limited to stale headers.

  Restore the previous GET-equivalent behavior with `api_platform.enable_head_request_optimization: true`.

Introduce a flag in the configuration:

->booleanNode('enable_head_request_optimization')
      ->defaultTrue()
      ->info('Skip response body construction on HEAD requests so collections are not iterated (no row SELECT). Disable to process HEAD identically to GET (full serialization + cache-tag
  headers).')
  ->end()

  Config key: api_platform.enable_head_request_optimization

This would solve my concerns about this, have a clean upgrade path + optimize but totally (don't leave out cache tags)

@Nayte91 Nayte91 force-pushed the perf/head-skip-body branch from 67527f7 to c88d8ac Compare June 24, 2026 19:42
@Nayte91 Nayte91 force-pushed the perf/head-skip-body branch from c88d8ac to 11e2d91 Compare June 24, 2026 19:57
@Nayte91

Nayte91 commented Jun 24, 2026

Copy link
Copy Markdown
Author

Hi @soyuka, thanks for the review!

I understand that you already know omitting these headers on HEAD is allowed by RFC 9110 (§9.3.2). So I think your point about the flag is not about correctness, but about the backward compatibility contract: even a correct change is still an observable change, and under semver it should not silently change behavior in a minor version.

I added a second commit for this: enable_head_request_optimization, which is true by default (so users get the optimization automatically), but can be set to false to fully restore the previous GET-equivalent behavior. I believe this answers the BC concern, for Symfony and Laravel.

One note: the CHANGELOG seems to be generated from commit messages at release time, so I kept the entry you suggested, but I can move it to an UPGRADE guide instead if you prefer.

Let me know if this matches what you had in mind? And if my implementation is what you expected!

HEAD responses now omit some observable headers (Content-Length, per-item cache tags); per semver that is a backward-
incompatible behavior change, so this commit ships an opt-out flag — set `enable_head_request_optimization: false` to restore the
prior GET-equivalent behavior.
@Nayte91 Nayte91 force-pushed the perf/head-skip-body branch from e8c12ff to 7726f2e Compare June 24, 2026 21:37
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.

2 participants