Purges that never purged

Published by Gonzalo Tudela Chavero

The symptom

A content site with 43 languages, each on its own subdomain, behind three Varnish nodes. Publishing worked and the cache plugin reported every purge as done. But translated articles kept serving their old version, and nothing ever failed.

The reason nothing failed is the first lesson: Varnish answered 200 to every PURGE, whether it evicted something or not. Even for a URL that had never existed. The status code carried no information at all.

The only honest signal

The Age response header says how long an object has been in the cache. If a purge works, the next request is a miss and Age drops to 0. If it does not, Age keeps growing by exactly the time that has passed. So every test became the same ritual: prime the page, read Age, purge, read Age again.

Clearing a single language invalidated nothing at all.

Four defects

1. The wrong Host

The plugin computed the right URL for every language, but every PURGE went out with the Host of the default domain. Varnish scopes bans by host, so 42 of 43 languages were never touched. The culprit was another add-on hooked on the same filter, which ignored its input and returned the home host. Running after it (priority 20, not 10, and not both: the order matters and was measured) fixed it.

2. A rate limit nobody could see

With the host fixed, things barely improved. The nodes rate-limited PURGE to about 2 per second per client and answered 429 Too Many PURGEs. Nobody saw it because the plugin sent purges non-blocking: an empty response looks exactly like a success.

The two defects masked each other. With every purge aimed at the same wrong object, 43 attempts meant one survivor was enough to look fine. Fix only the host and each language gets a single attempt, most of them dropped.

Fix: blocking requests (1-10 ms per node), retries that honour Retry-After, and per-node pacing that was later set to zero when the hosting removed the limit.

3. The right host, the wrong article

Measuring every request sent while purging one post: 378 of 504 PURGEs went to the default host with the Spanish slug. The multilingual plugin returns permalinks in the current language, and cron, CLI and REST requests run in the default one. The editor never showed the bug, because admin requests skip that rule. Fix: rebuild the purge list inside the post's own language, with try/finally so a failure cannot leave the request in the wrong one.

4. Percent-escapes in upper case

The last one appeared while verifying the third: a German article purged with the right host and the right path, 200, and still a HIT.

WordPress sends every HTTP request through a library that normalises percent-escapes to upper case. The site links Zuh%c3%b6rens, so that is what Varnish caches; the purge arrives as Zuh%C3%B6rens. Varnish compares bytes. They are two different objects.

Seven variants sent by hand, each checked with Age:

The fix sends those purges raw with cURL and widens every hex letter into a character class, so both spellings go in one request. Verified on two articles with two different escape shapes, with untouched sibling pages as negative controls each time.

This one is not about translations: it affects every non-ASCII slug on every site that purges Varnish through WordPress' HTTP API.

The result

A full purge of one post and its 42 translations now takes 1.67 s for 504 PURGE requests, 111 ms per translation, with every language evicted.

That number had a consequence. Earlier I had built a background queue with circuit breakers to keep slow purges out of the publishing request. Once inline purging cost under two seconds, the queue no longer paid for itself, so I measured both paths and retired it.

Takeaways