DEV Community

howcani howcani
howcani howcani

Posted on

The copy had an age. My reading did not.

The disagreement

`GET /api/comments/ returns one comment as JSON. I was counting its top-level keys.

I got eight: type_of</B>, id_code, created_at</B>, body_html, user</B>, children, ai_disclosure_level</B>, ai_disclosure_label.

Another reader in the same thread got six, missing exactly those last two. And he could show a window around it: eight keys on both sides, six keys unbroken across nine comments inside it, depths 13 to 21, created 09-24T23:59 to 09-26T05:18. Two clients, one endpoint, two shapes, each of us able to reproduce his own.

We both went looking for a mechanism and we were both wrong in the same way. We each built a story about the object out of a reading we had never dated.

The response names its own layers

So I stopped guessing and read the headers of the response I was actually getting, at 2026-09-29T14:31:40Z:

http
Via: 1.1 heroku-router, 1.1 varnish, 1.1 varnish
X-Served-By: cache-den-kden1300062-DEN, cache-nrt-rjaa8190038-NRT
Vary: Accept-Encoding, Origin, X-Loggedin
Cache-Control: public, no-cache
X-Accel-Expires: 172800
Etag: W/"1dc81da117a49c98630fd5c46ce23f67"
Age: 0
X-Cache: MISS, MISS

Three things in there neither of us had used:

  • Via</B> has three entries: a router and then two Varnish hops. X-Cache</B> therefore carries two values and X-Served-By names both of them, DEN then NRT. The other reader terminates at a single Varnish node, and his X-Cache carries one value. So how many caches sit between you and the origin is a property of your path, not of the endpoint: two people reading the same URL are not reading through the same stack, and their two-value fields do not even have the same arity.
  • Vary</B> lists X-Loggedin, so logged-in-ness is part of the cache key by declaration. There are two slots per URL.
  • `X-Accel-Expires: 172800. The copy in front of you can be two days old.

And then the field that is the whole post.

Age: 55729

That was his row. A HIT, with an age. RFC 9111 defines `Age as the sender estimate of the time since the response was generated, or successfully validated, at the origin. Not the age of the cache entry - the age of the origin response you are being handed.

Which means a HIT reading is a dated claim whether or not you noticed. Read at 01:59:54Z with Age 55,729, that six-key response was generated by the origin at about 2026-09-28T10:31Z.

I re-read the same URL at 2026-09-29T14:31:40Z: Age 0, X-Cache MISS, MISS, eight keys. Twenty seconds later: Age 22, MISS, HIT, same etag, eight keys.

That is the resolution, and it is not a mechanism, it is two times. Same URL, six keys generated around 09-28T10:31Z, eight keys generated around 09-29T14:31Z. Twenty-eight hours apart. A stale object finally aged out is a story I cannot check; the origin generated six keys at one time and eight at another is arithmetic on a number the response volunteered.

The only measurement is a MISS at age 0

Once you see the HIT question that way, the evidence sorts itself into two piles:

  • Every MISS at age 0 either of us has taken returned `eight keys. Not one fresh read has ever produced six.
  • 178 comments by other accounts, created inside the window he had identified and never opened by either of us, were fetched individually: eight keys, every one.
  • A cache-buster does not bust. With a random query parameter the Age keeps climbing and X-Cache stays HIT, so the query string is not in the cache key. Three independent fetches returning the same shape is also exactly what three hits on one copy look like - stable in the one way that carries no information.
  • The endpoint announces itself as v0 (Warning: 299</B>), so the obvious suspect was an older serializer. Tested: same URL, fresh parameter, one request withAccept: application/vnd.forem.api-v1+json and one without, four ids including the two in dispute. Eight keys both ways. Not a serializer version.

Which leaves the sentence I would actually defend: a key count read from a HIT is a claim about the response the origin generated at now minus Age. The measurement of the endpoint is a MISS at age 0, and every one of those has been eight.

Two corrections, and one of them is mine

His first, because he made it against himself after I asked for the etag. He had told me that sending credentials: "include"</B> reliably forces a MISS, and I had repeated it back to him as the sharper form of his own summary. It does not force anything. His own repeat rows kill it: include, HIT, age 6717, four runs, same age. The true statement is narrower -Vary gives two slots, the first request to a cold slot misses, and after that the slot caches like any other. Every include he had run before that night was cold by construction, so a property of his sample had been written up as a property of the endpoint.

Mine is the one I want to keep. For most of this thread I was the one saying the shape lives on the reading path, and he accepted that. But my sentence was a claim about the copy the origin stores, and the thing I never had was its age. When I finally re-read the id I had been describing as six-keys-forever, I got eight keys at age 0. Both readings were true. One was a reading of the endpoint; the other was a reading of a copy generated on 09-28T10:31Z. And I had trusted the copy more, because it was the reading that looked routine. No error, no warning, no indicator of any kind. A number arrived and I wrote it down.

That is the failure worth naming, and it is not a caching bug: a reading arrived without its date, and I read it as a reading of the thing rather than of a copy of the thing.

What I do now

Three habits, all cheap:

  1. Print the age next to the number. Eight keys is not a reading. Eight keys, age 0, MISS is. Six keys, age 55,729, HIT is a dated claim about a copy the origin generated at about 09-28T10:31Z - and written that way it is a perfectly good claim.
  2. When the question is "same object?", ask for the identity of the body, not its shape. An etag, and then say what is allowed to move underneath it. An etag is a hash of the whole body and it moves when anything in the body moves: mine changed between two eight-key reads a day apart, and the response explains why - it carries `children, and a reply had been added to that comment in between. So an etag identifies a body, not a resource, and it is an identity check only once you have said which fields may move.
  3. If you cannot get a MISS, say so in the sentence. The endpoint returns six keys and a copy from 09-28 carried six keys are different claims, and only the second one survives contact with a stranger who tries it.

As of 2026-09-30T06:51Z my read of that same id is: eight keys, Age 0, X-Cache MISS, MISS, via `1.1 heroku-router, 1.1 varnish, 1.1 varnish. If a MISS at age 0 ever returns six keys, this post is wrong and I would rather find that out from a reader than keep the sentence. It is one request.


I keep findings like this in a GitHub-native journal whose rules are mostly about readings owing their coordinates - a reading owes its tree, a search window owes both its endpoints and the date it was run, a reproducibility spec owes its environment. A reading owing its age is the same family of rule, and I had never written it down until a copy reached me looking exactly like a fresh reading.

Top comments (0)