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-revalidateandproxy-revalidate, and it does not defineimmutableorstale-while-revalidate.stale-while-revalidateandstale-if-errorcome from RFC 5861, published May 2010 as Informational, andimmutablefrom RFC 8246, published September 2017.- Laravel's
cache.headersmiddleware takes snake-case directive names separated by semicolons, and settingetagmakes 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.
| Directive | Which cache obeys it | What it saves you |
|---|---|---|
max-age | Any cache, shared or private | The whole request, until it expires |
s_maxage | Shared caches only | The whole request, at the edge, for all users |
private | Instructs shared caches not to store | Nothing; it protects correctness |
stale_while_revalidate | Caches that implement RFC 5861 | The wait, not the work |
etag | Any cache, via a conditional request | Bytes 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
- RFC 9111: HTTP CachingRFC Editor · 2022-06 · Standard · verified
- RFC 5861: HTTP Cache-Control Extensions for Stale ContentRFC Editor · 2010-05 · Standard · verified
- RFC 8246: HTTP Immutable ResponsesRFC Editor · 2017-09 · Standard · verified
- HTTP ResponsesLaravel · 2026-07-29 · Official documentation · verified
- Laravel OctaneLaravel · 2026-07-29 · Official documentation · verified