My publishing pipeline posted an article. The API said 201 Created. Then my own verification step told me the article did not exist.
It was wrong. The article existed the whole time. What lied to me was a probe that returned HTTP 200 with perfectly valid JSON, and that is the part worth writing down, because a probe that fails loudly is a good day. This one succeeded.
Short version: if you verify a write by reading a public, cacheable URL, a shared cache can hand you an answer assembled before your write. Status code and JSON schema both look fine. The Age response header is what gives it away. Numbers below are from my own runs against dev.to/api on July 30, 2026, re-checked September 7, 2026.
The setup
I run an automated content pipeline. It writes a post, publishes it to a blog and to DEV, then verifies the result before it logs anything as done. The verification rule is deliberately paranoid: never trust the write response, go read the thing back from a second route.
So after POST /api/articles returned 201, the pipeline asked a different endpoint whether the post was really there:
GET https://dev.to/api/articles?username=0012303&per_page=5
That per_page=5 matters more than anything else in this post. Hold on to it.
The post was not in the list. Not first, not anywhere. Meanwhile the authenticated route, /articles/me/published, had it sitting at the top.
Two routes, same platform, same second, opposite answers.
What I assumed, and why I was wrong
I wrote down "indexing lag". It felt obvious. Public listings are cached and rebuilt, the write had just landed, give it time.
I want to be precise about how bad that guess was: I did not measure it. I put it in my own notes as a fact, and the next day my notes were the thing I trusted. Eight hours later the explanation collapsed, and it collapsed because I finally checked whether the post was reachable at all:
| route | post present? |
|---|---|
| author profile HTML | yes |
| RSS feed | yes |
/api/articles/latest |
yes |
/api/articles?username= |
no |
Three public routes had it. One did not. That kills the simple version of "indexing lag", the one where a single slow index feeds every public surface. It does not by itself prove a cache: separate per-endpoint indexes or read replicas would look the same from outside. I needed a header to tell those apart.
One header
The endpoint that disagreed takes a per_page parameter. I had been sending per_page=5, because that is the number I happened to write months ago. On a hunch I sent the same request with different values and looked at the response headers instead of the body:
per_page=5 -> Age: 54816 (15.2 hours) post absent
per_page=30 -> Age: 37487 (10.4 hours) post absent
per_page=100 -> Age: 34 post is the first row
per_page=17 -> fresh post is the first row
There it is. A shared cache sits in front of this endpoint and keys on the full URL, so every per_page value is its own stored response with its own age. The two values my pipeline and I had been hitting for months were already warm, and the warm copies had been assembled before my article was published. The values I had never sent missed the cache and came back fresh, with the post at the top.
I want to be careful about the next step, because this is where I originally got it wrong. In my first write-up I explained the warm values by saying 5 and 30 are "popular" numbers that other people request too. I never measured that, and when I re-ran the whole thing on September 7, 2026, it fell over: per_page=100 came back with Age: 1861 while 5, 30 and 17 were all fresh. The mapping had inverted.
Here is what I can actually show. I picked per_page=73, a value I had never sent, and hit it three times:
request 1 Age: 0 x-cache: MISS, MISS
request 2 (+22s) Age: 22 x-cache: MISS, HIT
request 3 (+44s) Age: 44 x-cache: MISS, HIT
The first request creates the stored copy. Every later request gets that same copy back, one second older each second, and asking again does not refresh it. The response also carries x-accel-expires: 172800, a 48 hour ceiling, which is roomy enough to explain a 15 hour old answer without anything being broken.
The popularity theory was not just unmeasured, it was impossible, and I should have seen it in the URL. That URL contains username=0012303. It is a listing of my own posts. Nobody else on the internet has any reason to request it. The only traffic that could ever have warmed it was mine.
So the mechanism is this: whichever per_page your code happened to send before your write is exactly the one that is poisoned afterwards, and polling it harder makes it staler rather than fresher. My pipeline had per_page=5 hardcoded, so my pipeline had personally warmed the one copy that would go on to lie to it. On September 7 the warm values were 5, 17, 30 and 100 for the boring reason that I had just requested those myself a few minutes earlier.
Why that one route and not the other three
DEV runs on Forem, which is open source, so I did not have to guess. When an article is published, EdgeCache::BustArticle purges a specific list: the article and its author and organization by surrogate key, /, /latest, /videos, /top/<interval>, /t/<tag>, /t/<tag>/latest, twelve variants of /api/articles?tag=<tag>&top=<i>, and the author's profile pages.
Read that list again and notice what is not on it. The only /api/articles path that gets purged is the one keyed by tag. /api/articles?username= is never busted by publishing. It just sits there until its 48 hour ceiling expires.
That is the whole bug, and it explains my table better than my cache theory did. The author profile pages are purged explicitly, which is why the HTML profile had the post. The route with no invalidation path is the exact route that lied. I will be honest about the edge of what this file proves: it accounts cleanly for the profile pages and for ?username=, and I have not traced /api/articles/latest or the RSS feed through to a specific purge, so for those two I am inferring rather than showing.
The cache, meanwhile, did nothing wrong. A 15.2 hour old copy under a 48 hour TTL is a cache working exactly as configured. The gap is invalidation, not caching, and those are different bugs with different owners.
Two more details make it worse. I sent per_page=5 three times in a row and watched the age:
Age: 54816 -> 54820 -> 54825
Three requests, and the answer just kept getting older. And the response carries this:
Cache-Control: public, no-cache
x-accel-expires: 172800
no-cache does not mean "do not store". RFC 9111 ยง5.2.2.4 says a cache may store the response but must successfully validate it with the origin before reusing it, and it says that to every cache, shared ones included. So on a plain reading, what I was watching should not happen. In practice x-accel-expires: 172800 is the header that is actually being obeyed here: a server-side cache runs on its own configuration, not on the directive it passes through to you.
I tried, afterwards, to force it from my side. None of it works, and it is worth knowing which none:
Cache-Control: no-cache (in the request) -> ignored, still Age: 1444, x-cache: MISS, HIT
If-None-Match: <the ETag> -> 304, x-cache: HIT, Age: 1445 (answered at the edge)
api-key: zzqq-not-a-real-key-991 -> still Age: 1449, same cached object
That last line is the one that stings, because "just use an authenticated route" was going to be my advice. A deliberately invalid api-key gets the same cached body, which means on this endpoint api-key is not part of the cache key at all. Vary here is Accept-Encoding, Origin, X-Loggedin, and my key is not in it. I had assumed authentication implied privacy. It does not; Vary decides that, and Vary is readable.
Which brings up the thing I got most wrong in my first pass. I wrote that I "had no way to see this from the body". True, and beside the point: nobody made me look at the body. Age, X-Cache, X-Served-By and X-Accel-Expires were in every single response the whole time.
I wrote an article about reading response headers after eight hours of not reading response headers. For the record, x-served-by: cache-den-kden1300053-DEN, cache-bma-essb1270068-BMA is Fastly, two tiers, which is also what x-cache: MISS, HIT is telling you.
And the cache did nothing wrong. A 15.2 hour old copy under a 48 hour TTL is a cache behaving exactly as configured. The gap is invalidation, not caching, and those are different bugs with different owners. The one I own is smaller and dumber: I built a verification step on a URL whose freshness I had never once checked.
The dangerous part is not the wrong answer
A probe that says "not there" when the thing is there is annoying. Here is what makes it a real bug rather than a curiosity: think about what a pipeline does when the write succeeded but verification says nothing landed.
It retries the write.
That is the natural, correct-looking recovery path, and it publishes the article a second time. I came within one automated retry of duplicating a post on a platform that has already penalised this account once for publishing at volume. I did not retry, and the only reason is a rule I had written down earlier for unrelated reasons: after a 201, never send the write again.
The same class of failure had nearly caught me on comments the day before, July 29. DEV has no write endpoint for comments, since the Forem API spec exposes GET /api/comments and GET /api/comments/{id} and nothing else, so the comment went through the web form and I verified it by reading the tree back. GET /api/comments/<parent> returned children: [] five times in a row while the comment was sitting there, live and visible, on the page. The obvious move, post it again, would have put a duplicate under someone else's article.
The stale probe does not just mislead you. It actively pushes you toward the one action that causes damage.
The fix
I stopped treating "a public URL returned 200 with valid JSON" as evidence of anything.
The check below is runnable locally as written, against any endpoint you like. It is not a drop-in for your pipeline: the interesting decision, what to do when the answer comes back unknown, is yours and depends on how expensive a false alarm is for you.
# runnable local: python3 probe.py (needs: pip install requests)
import sys
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
import requests
class Indeterminate(Exception):
"""The probe cannot prove the answer is newer than the write."""
def read_back(url, *, write_completed_at, expect_id, api_key=None, timeout=10):
headers = {"User-Agent": "read-back/1.0"}
if api_key:
headers["api-key"] = api_key
r = requests.get(url, headers=headers, timeout=timeout)
r.raise_for_status()
# When did the origin actually generate this body?
# Date is when the response left the cache; Age is how long it had been sitting there.
if "Date" not in r.headers or "Age" not in r.headers:
# No Age at all is NOT proof of freshness. Say so out loud.
raise Indeterminate("no Date/Age headers; freshness unknown")
served_at = parsedate_to_datetime(r.headers["Date"])
generated_at = served_at.timestamp() - int(r.headers["Age"])
if generated_at < write_completed_at.timestamp():
raise Indeterminate(
f"answer was generated {write_completed_at.timestamp() - generated_at:.0f}s "
f"before the write finished; it cannot contain it"
)
# Fresh enough is still not the same as correct. Look for the thing you wrote.
body = r.json()
if not any(item.get("id") == expect_id for item in body):
return False # genuinely absent from a provably post-write answer
return True
if __name__ == "__main__":
# Pretend the write finished a minute ago. In real use, pass the real timestamp.
write_time = datetime.now(timezone.utc) - timedelta(minutes=1)
try:
ok = read_back(
"https://dev.to/api/articles?username=0012303&per_page=5",
write_completed_at=write_time,
expect_id=int(sys.argv[1]),
)
print("present" if ok else "absent (and the answer is provably post-write)")
except Indeterminate as e:
print(f"indeterminate: {e}") # <- do NOT retry the write here
Three outcomes, not two, and the third one is the entire point. Run it against a per_page your code has used before and you get indeterminate instead of a confident wrong answer:
$ python3 probe.py 4435238
indeterminate: answer was generated 68s before the write finished; it cannot contain it
A two-outcome probe would have printed absent there, and something downstream would have tried to fix it.
One caveat if you go reproduce this. The copy you are handed depends on which POP answers you, so the same URL can come back with wildly different ages minutes apart. While writing this paragraph I measured per_page=5 at Age: 1668 from the shell and then got a 128 second old copy from the same URL in the script run above. That variance is noise in the reproduction, not in the finding: what stays constant is that a stale copy answers 200 and the header is the only thing that says so.
Three rules came out of it:
-
Read
Ageon every public verification request. If the answer was generated before your write finished, it cannot contain your write, and no amount of JSON schema validation will tell you that. Note the asymmetry: a largeAgedisproves freshness, but a small one does not prove your object is in there. Only finding the ID does that. -
Verify by unique identifier, not by scanning a list. This was available the entire time and I missed it.
POST /api/articleshands back anid, andGET /api/articles/{id}is unique by construction, so it cannot collide with a shared listing somebody warmed last Tuesday.Both of my earlier instincts were worse. "Use an authenticated route" is unreliable here, because
api-keyis not in this endpoint'sVaryand a junk key gets the same cached body. "Pick aper_pagenobody else uses" is worse still: it works exactly once, since your own first request creates the copy that then goes stale on you. Doing it properly would mean a fresh value every run, inflating someone else's cache key cardinality to cover for your design. Query a URL that is unique because of what you wrote, not because you got creative. Never blind-retry a write that is not idempotent. The narrow lesson is not "never write twice", because idempotency keys and unique slugs exist and are the right answer. It is that a contradictory verification result is not permission to fire the write again. Wait, re-read through a different route, and if you still cannot tell, stop and ask a human.
The thing I keep thinking about
My verification step was written specifically because I did not trust the write response. It was the careful part of the system. And it was the part that lied, because I had checked the shape of the answer, status code and JSON validity and schema, and never once asked when the answer was true.
A negative result from a probe you have not validated is not information. It is a second thing to debug, and you will usually debug it in the wrong direction, because the probe looks healthy while it does it.
My first instinct for a fix was a positive control: keep something in every check that must appear in the result, and if it goes missing, blame the probe instead of the world. I still think that is a good habit. But I want to kill my own punchline, because when I did the arithmetic it did not survive.
The stale copy was 54,816 seconds old at 09:37 UTC on July 30, which puts its assembly at roughly 18:23 UTC on July 29. My previous article went live at 01:28 UTC on July 29, sixteen hours before that. So the previous article was sitting right there in the stale response, at the top of the list. A positive control keyed to it would have come back green while the probe was actively lying to me about the new post.
That is the uncomfortable version of the lesson. A positive control only proves your probe can see things that were true when the copy was made. It cannot distinguish a live answer from a preserved one. The only thing in that entire response that could tell those apart was a header I was not reading.
What is the cheapest check in your pipeline that you have never actually tested against a stale answer? ๐
Written with AI assistance. Every header value, Age reading and status code above comes from my own runs against dev.to/api on July 30, 2026 and September 7, 2026, and is pasted from the terminal rather than retyped. The Forem purge list was read from the linked source file on September 7, 2026. The one number I did not re-measure is the original eight hour incident window, which comes from my own logs from that day.
Top comments (0)