HTTP has two caching mechanisms most developers know about. The first is Cache-Control: directives that tell clients and proxies how long to keep a response. The second is conditional requests with ETag and Last-Modified, which are about confirming a cached response is still fresh without re-downloading the body.
The first mechanism is well-understood. The second is where a lot of applications leave performance on the table.
The Problem ETags Solve
Cache-Control: max-age=300 tells the client to keep a response for 5 minutes without checking back. Great for assets that genuinely do not change. But for resources that might change — a user's profile JSON, an API response, an HTML page that updates occasionally — max-age is a tradeoff: short, and you re-download often; long, and the user sees stale data.
ETags break the tradeoff. The client sends back the ETag it has; the server checks whether the resource has changed and either returns the new version or replies "still fresh, use yours." The body of an unchanged resource never crosses the wire.
# First request
GET /api/users/me HTTP/1.1
HTTP/1.1 200 OK
ETag: "v3-7f8b3c91"
Cache-Control: private, max-age=60
{"id": 123, "name": "Jane Doe", ...}
# Second request, after 60 seconds
GET /api/users/me HTTP/1.1
If-None-Match: "v3-7f8b3c91"
HTTP/1.1 304 Not Modified
ETag: "v3-7f8b3c91"
The 304 response is essentially a few bytes of headers — no body. For a 5KB JSON response that changes rarely, this is a 99% bandwidth reduction.
ETag Generation
The simplest ETag is a hash of the response body:
class UserController
{
public function show(Request $request, User $user)
{
$body = json_encode($user->toArray());
$etag = '"' . hash('xxh128', $body) . '"';
if ($request->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
return response($body, 200, [
'Content-Type' => 'application/json',
'ETag' => $etag,
'Cache-Control' => 'private, max-age=60',
]);
}
}
This always-correct approach computes the response, hashes it, and either returns 304 or 200. The catch: you have already done the work to compute the response. The bandwidth saving is real; the CPU saving is not.
A smarter version computes the ETag from input data, not output. For example, last_updated_at plus the user's ID gives a stable token that changes when (and only when) the resource changes:
public function show(Request $request, User $user)
{
$etag = '"' . hash('xxh128', "{$user->id}:{$user->updated_at}") . '"';
if ($request->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
// Only now do we serialize the response
return response($user->toArray(), 200)
->header('ETag', $etag)
->header('Cache-Control', 'private, max-age=60');
}
This skips the serialization entirely when the client's cache is valid. Both CPU and bandwidth saved.
Strong vs Weak ETags
ETag: "v3-7f8b3c91" is a strong ETag — byte-for-byte identical responses are required for the same value. ETag: W/"v3-7f8b3c91" is a weak ETag, indicating "semantically equivalent."
Strong ETags are required for range requests (resuming downloads); weak ETags are fine for everything else and easier to generate consistently. If you compress responses, you almost certainly want weak ETags — the compressed bytes differ slightly across compression library versions, but the meaning is identical.
ETag: W/"v3-7f8b3c91"
Last-Modified — The Older Cousin
Last-Modified and If-Modified-Since predate ETags and do essentially the same job using timestamps.
HTTP/1.1 200 OK
Last-Modified: Mon, 12 May 2026 14:32:01 GMT
# Conditional request
GET /api/users/me HTTP/1.1
If-Modified-Since: Mon, 12 May 2026 14:32:01 GMT
Use ETags by default. Timestamps have one-second resolution; ETags have arbitrary precision. ETags also handle the case where a resource is modified, then reverted to its prior state — the ETag returns to its prior value; the timestamp does not.
Last-Modified is still useful when you have natural timestamp data on the resource and do not want to hash anything. Either works.
Where ETags Pay Off
- API endpoints with periodic clients. A mobile app polling for a user's notifications every 30 seconds is a perfect ETag use case. Most polls return 304 with negligible cost.
- HTML pages that change rarely. A marketing page that updates monthly can be cached aggressively with an ETag that changes when the content does.
- Asset responses. Hashed asset URLs (
app-7f8b3c.js) make this less critical — the URL itself acts as a cache key. But for assets that cannot be hashed in the URL, ETags help.
Where ETags Do Not Help
- Responses that genuinely change every request. A live counter, a search result with relevance scoring, anything where the body is freshly computed each time.
- POST and PUT responses. ETags apply to GETs. Mutations have other patterns.
- Responses small enough that the headers are most of the bytes. A 200-byte response has overhead from the request line, headers, and the conditional request mechanics. The win is marginal.
ETags and Authorization
If a response varies by user — and most do — the ETag must vary too. An ETag for /api/users/me that is the same for every user means user A's ETag could be sent by user B, who would receive user A's response.
This usually does not happen in practice because the response body is different per user, and the body-hashed ETag is therefore different too. But if you compute ETags from input data (like the smarter version above), you must include user identity in the input:
$etag = '"' . hash('xxh128', "{$request->user()->id}:{$user->id}:{$user->updated_at}") . '"';
Generally: any data that affects the response body must affect the ETag.
ETags and Conditional Updates
ETags also enable conditional updates — write operations that succeed only if the resource has not changed since the client last read it.
PUT /api/users/me HTTP/1.1
If-Match: "v3-7f8b3c91"
Content-Type: application/json
{"name": "Jane Smith"}
# If the ETag still matches
HTTP/1.1 200 OK
ETag: "v4-9a2d4e08"
# If someone else updated in the meantime
HTTP/1.1 412 Precondition Failed
This is HTTP's native optimistic concurrency control. Stripe, GitHub, and many production APIs use this pattern. It saves the application from inventing its own version-column protocol.
CDN Considerations
A CDN sitting in front of your application can cache responses and serve them without consulting the origin. ETags interact with this in subtle ways:
- For public responses, the CDN caches the response and revalidates with the origin using the ETag on expiry. Less origin load.
- For private (per-user) responses, the CDN cannot cache. Headers must include
Cache-Control: privateto prevent the CDN from serving one user's data to another. - For mixed responses, consider whether per-user data really needs to live in a response that could otherwise be public. Splitting endpoints (public catalog vs personalized recommendations) lets each be cached appropriately.
A Practical Setup
For most APIs:
- Use ETags on GET responses for resources that change in identifiable ways
- Compute ETags from input data (record IDs and timestamps) when possible to skip serialization
- Use weak ETags unless you specifically need byte-equality
- Pair short
Cache-Control: max-agewith ETags — the cache is invalidated quickly, but revalidation is cheap - Use
If-Matchon critical mutations for optimistic concurrency
The mechanism is built into HTTP, the libraries handle most of it, and the bandwidth savings on cached resources are substantial. The infrastructure is already there; you just have to use it.
Reviewing an API that has grown to make a lot of repeated requests for slowly-changing data? We help teams put the boring parts of HTTP to work before reaching for caching layers. scopeforged.com