Hello, this is Leslie from the Femiwiki development team. Femiwiki keeps the pages it shows to logged-out visitors in a cache we built ourselves, called caddy-mwcache. In this post I would like to introduce how this cache was made, and a bug we recently fixed that kept it from holding more than 17 pages. This post is written for readers who already know about web servers and HTTP caching.
Background
When a page changes, MediaWiki sends a PURGE request to that page's address to tell the cache in front of it to drop the old copy. Wikipedia receives these requests with Varnish,[1] but Femiwiki had only one server, which made a separate cache server hard to justify, and it was already using Caddy as its web server. So when CPU usage suddenly rose in December 2020, we decided to cache requests from logged-out visitors,[2] built it ourselves as a Caddy module, and released the first version in April 2021.[3][4]
How the cache is built
Responses are stored in Ristretto, an in-memory cache library written in Go, and whether to cache something is decided by rules similar to Wikimedia's Varnish. For example, a request with a session or token cookie, or an Authorization header, may come from a logged-in user, so it bypasses the cache;[5] a response carrying private, no-cache, no-store or Set-Cookie is not cached; and how long a response is kept follows the s-maxage that MediaWiki sends.
sessionCookiePattern = regexp.MustCompile(`([sS]ession|Token)=`)
func hasSessionCookie(r *http.Request) bool {
cookie := r.Header.Get("Cookie")
return sessionCookiePattern.MatchString(cookie)
}
By the way, when we first built it, PURGE requests never reached the cache. When $wgInternalServer is empty, MediaWiki sends PURGE to $wgServer, which is an https://… address, and Caddy took that as a proxy tunnel (CONNECT) request and passed it on over HTTPS.[6] We fixed this by setting $wgInternalServer to http://127.0.0.1:80.
The cache that held only 17 pages
In September 2026 we noticed that the main page was always served from the cache, while an ordinary page was rendered anew no matter how many times it was reopened. Opening the same address twice and comparing the x-request-id attached to each response told them apart, because a response from the cache carries the value it had when it was cached.[7]
/w/페미위키:대문 x-request-id: 539669cff53359cf8874d69d
/w/페미위키:대문 x-request-id: 539669cff53359cf8874d69d
/w/설경구 x-request-id: b32ef97ff7d12cf14c30ce70
/w/설경구 x-request-id: fd9e121cfaf1ee53b06a97ca
Two causes overlapped. First, every entry was given a cost of 1 when it was stored, so the max_cost setting counted entries rather than bytes. On top of that, unless IgnoreInternalCost is turned on, Ristretto adds 56, the size of its internal struct, to the cost of every entry,[8] so an entry with a cost of 1 actually took up 57, and the cache could only hold max_cost divided by 57. Putting 5,000 entries into a cache with a MaxCost of 1,000 gives this:
held: 17
hit: 17 miss: 4983 keys-added: 5000 keys-evicted: 4983 cost-added: 285000 ... hit-ratio: 0.00
So we added a new setting, max_cost_bytes, which costs each entry at the response's size in bytes. Since someone somewhere might be using caddy-mwcache, which is open source, we left the existing max_cost as it was for backward compatibility, and when counting in bytes, the internal struct size is counted too, because it is memory actually in use.[9]
cost := int64(1)
if m.costInBytes {
cost = int64(len(val))
}
Hit rate
Even after the cache grew from 17 entries to 1,000, and then to 64 MiB, the number of requests served from it stayed at about 155 to 195 every five minutes. To find out why, we counted the page requests that came in during one hour from 11 a.m. on September 25 (KST).[10]
| Requests | 1,691 |
| Distinct addresses | 1,545 |
| Repeat requests for the same address | 146 (8.6%) |
| Requests served from the cache | 96 |
| Cache usage | 19.7 MB of 64 MiB |
In other words, with only 146 repeat requests for the same address, no amount of extra cache size looked able to get more hits than that. 1,498 addresses were requested exactly once, which is just what a crawler sweeping a wiki from start to finish looks like; for example, one IP address made 247 requests at intervals of 3.5 to 5.7 seconds.
Other improvements
We could not raise the hit rate, but along the way we made the following improvements.
- Caddy was compressing responses taken from the cache with gzip all over again every time, and about 60% of the CPU Caddy used on the production server went to compression. Now a response is compressed once when it is stored and sent out as it is.[11]
- Because
load.phpdoes not use sessions (MW_NO_SESSION), it is now served from the cache even when a session cookie is present. A crawler that picked up a cookie on the account creation page had been making the server rebuild a stylesheet every few seconds.[12] - Following Wikimedia's
static.php,[13] static files whose address carries a version hash, such as skin icons, are now marked as cacheable for a year when the hash matches the file on disk. - The host is now part of the cache key, so pages from different hosts cannot get mixed up even if different hosts serve different pages someday.[14][15]
- When several requests ask for the same page at once, MediaWiki is asked only once,[16] and when the browser's copy is still current, the cache answers
304without a body and adds anAgeheader.[17] - Pages are stored separately for each visitor language, so the cache works with the automatic interface language selection we recently turned on.[18]
Closing
When we first introduced the cache, I thought that for a wiki a bigger cache was always better, and vaguely assumed it would be doing something, but recently, with crawlers making up most of Femiwiki's traffic, I saw in numbers for the first time that it does very little. Now Femiwiki also has CloudFront in front of it, so requests such as load.php are mostly served by CloudFront and caddy-mwcache takes the remaining pages behind it, among many other improvements.
caddy-mwcache is published under the AGPL, so if you run MediaWiki on a single server, we would be glad if you considered giving it a try.
References
- ↑ “Manual:Varnish caching”. 《MediaWiki》 (영어). 2026년 11월 1일에 확인함.
- ↑ “Cache anonymous request · Issue #219 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “Implement cache · Pull Request #3 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “Deploy caddy-mwcache · Pull Request #491 · femiwiki/docker-mediawiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “caddy-mwcache/mwcache.go at main · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “테스트 서버 상에서 caddy-mwcache 사용 중 purge가 안 됨 · Issue #239 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “caddy-mwcache: make max_cost a byte budget · Issue #452 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “ristretto/cache.go at v2.4.2 · dgraph-io/ristretto”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: add max_cost_bytes, a budget spent in bytes · Pull Request #141 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “caddy-mwcache: caddy reload drops the whole cache · Issue #454 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: store pages gzipped · Pull Request #164 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: serve load.php from the cache to requests with a session cookie · Pull Request #167 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: add a static block that caches static files by their version hash · Pull Request #160 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “The cache key has no host · Issue #170 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat!: put the request's host in the cache key · Pull Request #171 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: coalesce concurrent misses for one page · Pull Request #197 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: answer conditional requests on hits and send Age · Pull Request #198 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
- ↑ “feat: store a variant per Accept-Language with vary_accept_language · Pull Request #207 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.
