Laravel

HTTP: Payloads, Compression, and the Edge

Response size, cache headers, and work you can stop doing entirely.

Chapter 9 of 1311 min readOpen access

HTTP: Payloads, Compression, and the Edge is the only chapter in this book where the available move is to stop doing the work rather than to do it faster. A response served from a cache in front of your application does not consume a worker, a connection or a query, which makes it the one lever that reduces arrival rate rather than service time.

Key takeaways

  • RFC 9111, published by the RFC Editor in June 2022 on the Standards Track and obsoleting RFC 7234, defines a shared cache as one storing responses for reuse by more than one user and a private cache as one dedicated to a single user.
  • The same document defines ten Cache-Control response directives including max-age, s-maxage, private, public, must-revalidate and proxy-revalidate, and it does not define immutable or stale-while-revalidate.
  • stale-while-revalidate and stale-if-error come from RFC 5861, published May 2010 as Informational, and immutable from RFC 8246, published September 2017.
  • Laravel's cache.headers middleware takes snake-case directive names separated by semicolons, and setting etag makes it use an MD5 hash of the response content as the ETag.
  • Because that ETag is computed from the response content, a conditional request that returns 304 has already cost you the full render. It saves bytes on the wire, not work in the worker.

Read this after Chapter 6, which set the staleness rule for caches you own, because this chapter applies the same rule to caches you do not own and cannot purge. Chapter 10 covers the traffic this chapter cannot help with, which is the request a component fires because a user typed a character.

A public pricing page is rendered on every request. It changes twice a year.

The page carries a session cookie, because every route in the application does, so the CDN in front of it stores nothing. Every crawler, every share preview and every returning visitor consumes a worker to receive bytes identical to the last set.

The page is not slow. It is just being generated four hundred thousand times a month for no reason.

The cheapest response is the one you never generate

There are exactly three moves available at this layer, and they are worth separating because teams conflate them and then optimise the least valuable one.

Do not generate the response, because a cache in front of you served it. Do not send the response body, because the client already holds a current copy and you returned a 304. Or send fewer bytes, through a narrower payload or compression.

Their value is in that order and it is not close. The first removes arrival rate from Chapter 2's arithmetic. The second removes bandwidth and leaves service time intact. The third reduces transfer time at a small CPU cost.

Most performance advice at this layer is about the third. The first is where the capacity is. Start there. Compression can wait.

Shared and private caches are two different machines

RFC 9111 is short, readable and worth an afternoon, because the vocabulary it fixes is the vocabulary every CDN configuration screen uses. It was published in June 2022 on the Standards Track and obsoletes RFC 7234.

Its two definitions do the most work. A shared cache stores responses for reuse by more than one user and is usually deployed as part of an intermediary. A private cache is dedicated to a single user and is often a component of the user agent. Every directive is meaningful only against one of those two.

That distinction explains the pair people misuse most. max-age applies to any cache, while s-maxage applies specifically to shared caches, which lets you keep a copy at the edge for five minutes while telling browsers to hold it for thirty seconds. private forbids shared storage entirely, and public permits it even where heuristics would not.

must-revalidate and proxy-revalidate are the correctness pair, forbidding the reuse of a stale response, with the second applying only to shared caches. Together with no-cache, no-store, no-transform and must-understand, that is the standard set.

Decide per route, in writing. Which of the two caches may hold this, and for how long.

The extensions you rely on are not in the caching standard

This is a small piece of trivia with a practical consequence, which is why it belongs in the chapter rather than a footnote.

RFC 9111 does not define immutable and does not define stale-while-revalidate. Those live elsewhere. RFC 5861, published May 2010 as an Informational document, defines stale-while-revalidate and stale-if-error. RFC 8246, published September 2017, defines immutable.

The consequence is that support for the two most useful modern directives is per-implementation rather than guaranteed by the core specification. A CDN may honour stale-while-revalidate and a corporate proxy may not, and both are conformant.

So treat those two as optimisations that degrade rather than as guarantees. Set the base max-age so that behaviour is acceptable when the extension is ignored, then add the extension for the caches that implement it.

stale-if-error is the underused one, and it is the closest thing HTTP has to a circuit breaker you do not have to operate. A cache that may serve a stale copy when your origin returns an error converts an outage into staleness.

Laravel maps the directives, in snake case

Laravel's response documentation gives you the whole mechanism in one middleware, and the syntax is unusual enough to be worth memorising.

Directives are given in the snake-case equivalent of the Cache-Control directive and separated by semicolons. The documented example applies public, max_age=30, s_maxage=300, stale_while_revalidate=600 and etag to a route group. Note that the standard directive s-maxage becomes s_maxage here, which is the detail people get wrong on first use.

The etag entry does something with a hidden cost. Laravel documents that when etag is specified, an MD5 hash of the response content is automatically set as the ETag identifier.

DirectiveWhich cache obeys itWhat it saves you
max-ageAny cache, shared or privateThe whole request, until it expires
s_maxageShared caches onlyThe whole request, at the edge, for all users
privateInstructs shared caches not to storeNothing; it protects correctness
stale_while_revalidateCaches that implement RFC 5861The wait, not the work
etagAny cache, via a conditional requestBytes on the wire only

Apply this per route group and never globally. A global cache header is how a personalised page ends up in a shared cache, which is a data incident rather than a performance regression.

A conditional request saves bytes, not work

Follow the mechanism of that MD5 ETag and an important limit falls out. To hash the response content, the response content must exist, which means the controller ran, the queries ran and the view rendered.

So a request carrying If-None-Match that results in a 304 has consumed a PHP worker, a database connection and whatever else the route touches. What it saved is the transfer of the body. On a mobile connection that is worth having. On your capacity graph it is worth nothing. The worker was still held. The queries still ran.

To save the work you need a validator you can compute without rendering. A last-modified timestamp maintained on write, or a version counter per resource, or a cache key you can check before dispatching to the controller. Those are application design choices rather than header choices.

That is the honest hierarchy of this chapter in one paragraph. Headers alone move bytes. Removing the render requires knowing, cheaply, that nothing changed.

Compression is a CPU trade, usually worth taking

Compression is the one item on this list that costs you something on every request, which is why it belongs at the edge where possible rather than in PHP.

The mechanism sets the expectation. Text compresses well, so HTML, JSON, CSS and JavaScript are the whole opportunity. Already-compressed bytes do not, so compressing images, video or archives spends CPU to produce nothing. Applying compression indiscriminately is a small permanent tax.

Laravel's Octane documentation notes that FrankenPHP supports modern web features including early hints, Brotli and Zstandard compression, which is the vendor describing its own server. Early hints are the interesting half of that sentence, because they are not compression at all: they let the client start fetching subresources before your response body exists.

The Laravel-specific move that matters more than any codec choice is payload width, and Chapter 4 already gave you it. An API response carrying forty fields where the client reads six is a payload problem that compression only disguises.

Measure the payload before you tune the codec. Then tune the codec once, at the edge.

A worked reading, composited and labelled

The details here are composited from ordinary production shapes rather than one system, and the shape is exact.

The question is how much traffic could be served without touching the application at all. The first reading groups requests by route and counts, from the access log, how many are identical anonymous requests for content that changed less often than the request interval. That is not a latency measurement. It is a count of avoidable arrivals.

The second reading looks at what prevents each of those from being cacheable, and there are usually only three answers: a Set-Cookie header, a Vary header wide enough to shatter the cache key, or a private directive applied globally. All three are configuration rather than architecture.

The pairing is the useful part. A large avoidable-arrival count with one shared cause is the cheapest capacity work available in this book, and it does not require touching a query, a lock or a worker count. Chapter 2's arithmetic then tells you what the removed arrivals were costing.

The objection: put a CDN in front of it and be done

A CDN is the right answer and it fails silently when the application defeats it, which is the normal condition of a Laravel application on the day the CDN is switched on.

The usual defeater is the session cookie. A framework that starts a session on every route sets a cookie on every response, and most shared caches treat a response with Set-Cookie as not storable. The fix is scoping session middleware to the routes that need it, which is an application change rather than a CDN setting.

The second defeater is Vary. A response varying on a header with many values fragments the cache key until the hit ratio approaches zero, and the cache is now a slower route to your origin. Vary on the minimum, deliberately.

The third is that a shared cache cannot help a personalised page, and this is where teams reach for edge personalisation and add a distributed system to avoid removing a query. Chapter 6's staleness table is the cheaper answer: cache the expensive shared fragments on the server, and render the personal parts.

So the rule I would write down: a CDN removes arrivals only for responses that are identical across users and carry no cookie. Everything else is a server-side caching problem wearing a CDN costume.

Chapter summary

This is the only layer where the available move is to stop doing the work, and the three moves rank strictly: never generating the response removes arrival rate from Chapter 2's arithmetic, returning a 304 removes only bytes, and compression removes transfer time at a CPU cost. RFC 9111, published June 2022 on the Standards Track and obsoleting RFC 7234, fixes the vocabulary by defining a shared cache as one serving more than one user, usually in an intermediary, and a private cache as one dedicated to a single user, usually in the agent, which is what makes max-age and s-maxage different instructions rather than synonyms. It defines ten response directives and it does not define immutable, which comes from RFC 8246 of September 2017, or stale-while-revalidate and stale-if-error, which come from RFC 5861 of May 2010 as Informational, so both are optimisations that must degrade acceptably rather than guarantees. Laravel's cache.headers middleware takes snake-case directives separated by semicolons, with the documented example combining public, max_age, s_maxage, stale_while_revalidate and etag, and setting etag makes it hash the response content with MD5, which means a 304 has already cost you the render and saves only the wire. Saving the work needs a validator computable without rendering, such as a maintained last-modified timestamp or a version counter. Compression pays on text and wastes CPU on already-compressed bytes, and Laravel's Octane documentation records FrankenPHP as supporting early hints, Brotli and Zstandard. And a CDN removes arrivals only for responses identical across users and free of cookies, which is why session scoping and a narrow Vary matter more than any edge setting.

Not all traffic arrives because a person asked for a page. Some of it arrives because a component decided to check something. Chapter 10 is Front-End Cost You Are Paying on the Server, and it is about the round trips your interface generates on your behalf.

Sources

  1. RFC 9111: HTTP CachingRFC Editor · 2022-06 · Standard · verified
  2. RFC 5861: HTTP Cache-Control Extensions for Stale ContentRFC Editor · 2010-05 · Standard · verified
  3. RFC 8246: HTTP Immutable ResponsesRFC Editor · 2017-09 · Standard · verified
  4. HTTP ResponsesLaravel · 2026-07-29 · Official documentation · verified
  5. Laravel OctaneLaravel · 2026-07-29 · Official documentation · verified