The One-Second Bug Hiding in Your HTTP Cache
A few years back I was debugging a CDN cache that kept serving stale content for exactly the kind of resource it should have invalidated instantly: a JSON feed that updated multiple times per minute during a live event. Every other asset on the site cached fine.
This one kept handing out a version that was a few seconds old, even right after a fresh write. The cause turned out to be embarrassingly simple once I found it: the origin server was validating freshness with Last-Modified alone, and Last-Modified only has one-second resolution.
If the feed got rewritten twice inside the same second, both writes carried an identical timestamp as far as HTTP was concerned, and a client that showed up in that window had no way to tell the two versions apart. It wasn't a caching bug exactly. It was a precision bug wearing a caching bug's clothes.
That's the kind of thing you only really internalize once you've been burned by it, so here's the version I wish I'd read first.
Two headers, one job
Both ETag and Last-Modified exist to answer the same question on a repeat request: has this resource changed since you last saw it, and if not, can the server skip re-sending the body?
The server attaches one or both on the first response. The client echoes them back on the next request as If-None-Match (for ETag) and If-Modified-Since (for Last-Modified). If nothing's changed, the server answers with a 304 Not Modified and no body at all, and the client just reuses what it already has.
Last-Modified does this with a timestamp. ETag does it with an opaque token that's usually, though not always, derived from the content itself, like a hash of the response body. That difference in mechanism is where all the interesting behavior comes from.
Where the two actually diverge
A timestamp is only ever as precise as its clock, and HTTP dates are specified down to the second. Two writes within the same second are indistinguishable to Last-Modified, full stop, no matter how different the content actually is.
A content-derived ETag doesn't have that ceiling. Two different byte sequences essentially never collide on a hash, so a change from a millisecond ago is just as visible as one from last year.
There's also a strength distinction that only ETag has:
- A strong
ETag("abc123") promises byte-for-byte identity: two responses sharing one are guaranteed identical all the way down, which matters if you're resuming a partial download withIf-Rangeand need an exact guarantee. - A weak
ETag(W/"abc123") only promises semantic equivalence. A server might hand out the same weak token for a gzip-compressed and an uncompressed version of the same logical content, even though the bytes genuinely differ.
Last-Modified can't express either promise; a timestamp only ever supports the weaker claim.
And when a client sends both headers on the same request, the server is supposed to trust If-None-Match and ignore If-Modified-Since entirely rather than trying to split the difference. That's not an arbitrary tie-break. If you can do the more precise check, falling back to the coarser one buys you nothing.
Why Last-Modified hasn't disappeared
Given all that, the obvious question is why anyone still bothers with Last-Modified. The answer is cost. A file served straight off disk already carries a modification time as filesystem metadata, so the server gets it for free.
A genuinely content-derived ETag means hashing the response body, which isn't free on every single request for a large file. For a case where the resource changes at most once a second and the content is large enough that hashing has a real cost, Last-Modified alone is still a reasonable choice.
It's just a smaller slice of real traffic than it used to be, now that hashing is cheap and most frameworks compute an ETag automatically anyway.
The part that's easy to get wrong in production
If you're running behind multiple replicas, a Last-Modified timestamp depends on consistent file metadata across all of them. A deploy that recreates a file on disk without actually changing its content can shift the modification time and make the server report a change that never happened.
A content-derived ETag sidesteps this, as long as every replica computes the hash the same way. That consistency is its own trap, though: if one replica's hash includes something request-specific that another's doesn't, you've reintroduced the exact false-mismatch problem you were trying to avoid.
I ended up writing a small interactive breakdown of the actual precedence rules: what wins when both headers are present, how weak comparison works, and where the one-second blind spot shows up concretely instead of just as an assertion. It's over on Nodique if you want to see the mechanics side by side.
There's also a simulator where you can set a server's validators and a client's conditional headers and watch which rule actually fires, which made the precedence order click for me a lot faster than reading the RFC text did.
Send both headers when it's cheap to compute them. Reach for ETag alone when precision actually matters: content that can change faster than once a second, or anything served from replicas you can't fully trust to agree on a timestamp.
Comments
No comments yet. Start the discussion.