Incident: CDN serving the wrong locale
A page that picks its language must be cached under that language: compare what the page reads with what the key holds.
Predict first
Before you read: predict
PAGE · ECON-201, 08:47 · candidates at the Leipzig edge still see the exam lobby in Arabic after hotfix 2026.10.05-2
Release 2026.10.05-1 moved the lobby's language out of its path: /exams/ECON-201/lobby now picks German, Arabic or English from the language picker's locale cookie, else from Accept-Language. Hotfix -2 added Vary: Accept-Language to the lobby's response, which still carries Cache-Control: public, s-maxage=600. The CDN honours Vary. At 08:47 the Leipzig edge holds no copy of the lobby, and three candidates open it there, one after another:
- Amira: cookie
locale=ar; her browser sendsAccept-Language: de-DE,de;q=0.9 - Jonas: no cookie;
Accept-Language: de-DE,de;q=0.9 - Lena: no cookie;
Accept-Language: de-DE,de;q=0.9,en;q=0.8
How many of the three see the lobby in a language other than the one the release would pick for them?
The 30-second version
Some users seeing another language, flipping on a fixed period, is a copy cached by URL while the page reads a cookie or Accept-Language: the first miss after each expiry picks for everyone. Prove it through the edge, then at the origin. Vary: Accept-Language misses the cookie and splits on every header string; key a normalised locale, or the path.
A page that picks its language must be cached under that language: compare what the page reads with what the key holds.
How it works
Step 1 of 12
Analogy: A sightseeing boat's recorded commentary
Boats leave the pier every ten minutes, and each trip plays one recorded commentary. The skipper sets its language from the first passenger to board: from the language badge on her lanyard if she wears one, else by asking her.
| On the pier | In the incident |
|---|---|
| one commentary per trip, a new trip every ten minutes | one stored copy per key, until s-maxage ends (step 3) |
| the first passenger aboard sets the trip's language | the first miss after each expiry (step 3) |
| the lanyard badge, read before asking | the locale cookie, read before Accept-Language (step 6) |
| boats sorted by the exact words passengers ask with, most nearly empty | Vary on the raw header (step 5) |
| a boat per passenger | Vary: Cookie with a session cookie (step 7) |
| one signposted gangway per language | the locale in the path (step 9) |
| a booth reading the badge, then the request, into one of three boats, and telling the skipper | a normalised key passed to the origin (steps 9–10) |
| a pier-entrance arrow to "today's gangway", painted for the first visitor | a cached redirect (step 11) |
Breaks down when: a skipper hears the grumbling and switches language mid-trip; a stored copy never changes before it expires or is purged.
Contrasting case: a bug at the origin. The origin itself answers the wrong language, the same one every time for one input, with no Age, and purges or keys change nothing. Its Content-Language is the locale it picked: a wrong one is negotiation; the right one over another language's text is translation.
In real terms: when anything but the URL picks the language, the cache key must hold the language the page shows.
Examples, run for real
4.1 ExamHall EXH-3010: the Leipzig edge before the 09:00 start
Release 2026.10.05-1 serves the lobby by URL with Cache-Control: public, s-maxage=600 and no Vary, and picks its language from the locale cookie, else Accept-Language. Six candidate requests at the Leipzig edge model, then the on-call engineer's four requests at 08:56:
4.1 model edge Leipzig · lobby Cache-Control: public, s-maxage=600, no Vary · cache key: method and URL · the release picks the locale cookie, else Accept-Language
4.1 08:40:05 Nour (locale=ar; de-DE,de;q=0.9) · origin would pick Arabic · served Arabic · X-Cache MISS · Age 0
4.1 08:42:30 Tobias (no cookie; de-DE) · origin would pick German · served Arabic · X-Cache HIT · Age 145
4.1 08:46:10 Greta (locale=de; de-DE,de;q=0.9) · origin would pick German · served Arabic · X-Cache HIT · Age 365
4.1 08:50:07 Greta (locale=de; de-DE,de;q=0.9) · origin would pick German · served German · X-Cache MISS · Age 0
4.1 08:53:40 Nour (locale=ar; de-DE,de;q=0.9) · origin would pick Arabic · served German · X-Cache HIT · Age 213
4.1 08:58:00 Tobias (no cookie; de-DE) · origin would pick German · served German · X-Cache HIT · Age 473
4.1 candidate requests served another language than the origin picks for them: 3 of 6 · copies stored at 08:40:05 and 08:50:07, each for 600 s
4.1 on-call 08:56:00 through the edge, Cookie: locale=ar · Content-Language de · X-Cache HIT · Age 353
4.1 on-call 08:56:02 through the edge, Cookie: locale=de · Content-Language de · X-Cache HIT · Age 355
4.1 on-call 08:56:04 straight to the origin, Cookie: locale=ar · Content-Language ar · no Age
4.1 on-call 08:56:06 straight to the origin, Cookie: locale=de · Content-Language de · no Age
4.1 through the edge: one language for both cookies · at the origin: one per cookie
Each copy stays ten minutes, and the first miss after its expiry sets the language until the next (steps 1–3). Through the edge the engineer's two cookies both get German with an Age; at the origin each gets its own language, so the cause is the key, not the release's negotiation (step 4).
4.2 Payments ledger MR !3310: the locale moves into the path
A wallet's fee page /pricing/fees showed the same fault. MR !3310 serves /de/pricing/fees, /ar/pricing/fees and /en/pricing/fees with public, s-maxage=600; the bare URL answers a 307 to the visitor's locale, sent with Cache-Control: private, no-store. Six customers open the bare URL at one PoP and follow the redirect:
4.2 model PoP · /pricing/fees answers 307 to /<locale>/pricing/fees, sent Cache-Control: private, no-store · /de, /ar and /en pages sent public, s-maxage=600 · the wallet picks the lang cookie, else Accept-Language
4.2 14:00:00 Lukas (no cookie; de-DE,de;q=0.9) · 307 -> /de/pricing/fees (MISS) · page German (MISS)
4.2 14:00:30 Mariam (lang=ar; en-GB) · 307 -> /ar/pricing/fees (MISS) · page Arabic (MISS)
4.2 14:01:00 Chloe (no cookie; fr-FR,fr;q=0.9) · 307 -> /en/pricing/fees (MISS) · page English (MISS)
4.2 14:01:30 Karim (no cookie; ar-EG) · 307 -> /ar/pricing/fees (MISS) · page Arabic (HIT)
4.2 14:02:00 Paula (lang=de; en-US) · 307 -> /de/pricing/fees (MISS) · page German (HIT)
4.2 14:02:30 Ben (no cookie; en-GB,en;q=0.9) · 307 -> /en/pricing/fees (MISS) · page English (HIT)
4.2 customers shown their own language: 6 of 6 · copies stored at the PoP: 3 (one per locale path; the redirect none) · origin requests 9 of 12
Each language now has its own URL, so each is one key and one copy (step 9), and the redirect, never stored at the edge, sends each customer to their own (step 11).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A wallet's fee page /pricing/fees is sent with Cache-Control: public, s-maxage=3600 and Vary: Accept-Language, and picks German, Arabic or English from Accept-Language alone. The CDN honours Vary and rewrites no request header. Between 10:00 and 10:50, one PoP receives 1,000 requests for the page, and no stored copy expires in that time. Their Accept-Language values, with the number of requests carrying each:
de-DE,de;q=0.9,en;q=0.8 210
de-DE,de;q=0.9 160
de-DE 90
de-AT,de;q=0.9 60
de 40
ar-EG,ar;q=0.9,en;q=0.8 150
ar-EG,ar;q=0.9 80
ar 30
en-US,en;q=0.9 90
en-GB,en;q=0.9 60
fr-FR,fr;q=0.9,en;q=0.8 30
How many of the 1,000 requests reach the origin?
Question 2 of 7
A streaming service's home page /browse picks German, Arabic or English from the profile's lang cookie, else from Accept-Language, and is sent with Cache-Control: public, s-maxage=300 and no Vary. Every viewer is signed in, so each request carries lang and a session cookie sid. Viewers see the rows in other languages. The CDN honours Vary. The team will make one change and then purge /browse. Over the next 5 minutes one PoP gets 600 requests from 120 viewers, 5 each; 40 viewers have each language, and their browsers send 6 different Accept-Language values.
Which change leaves no viewer seeing a language other than their lang, and sends at most 60 of the 600 requests to the origin?
Question 3 of 7
A chat web app moved each language into the path: /de/, /ar/ and /en/. Its bare / answers a 307 whose Location is the visitor's locale, picked from Accept-Language, and its server sends Cache-Control: public, s-maxage=300 on every response, the redirect included. The PoP holds nothing at t = 0 s. Then, one at a time:
t = 0 s Rania Accept-Language: ar opens /
t = 40 s Felix Accept-Language: de-DE opens /
t = 320 s Emma Accept-Language: en-GB opens /
t = 330 s Felix Accept-Language: de-DE opens /
Where is Felix sent at t = 330 s?
Question 4 of 7
A ticketing site's event page /events/88 is sent with Cache-Control: public, s-maxage=600 and picks German, Arabic or English from the locale cookie, else from Accept-Language. To stop fans seeing other languages, the team set the CDN to compute a language from Accept-Language alone (the first tag's language if it is de, ar or en, else en) and add it to the cache key. Just after a purge, three fans open the page at one PoP, one after another:
Hana cookie locale=ar Accept-Language: de-DE,de;q=0.9
Paul no cookie Accept-Language: de-DE
Sami cookie locale=ar Accept-Language: ar-EG
In which language does each see the page?
Question 5 of 7
In the interview you are given EXH-3010's page and asked: "Some candidates see another language, and it changes every 10 minutes. Why?
Is Vary: Accept-Language the fix?" What do you answer, in at most 150 words?
Question 6 of 7
Release 2026.10.05-1 is still live at the Vienna edge: the ECON-201 lobby is sent with Cache-Control: public, s-maxage=600 and no Vary, the CDN keys it by URL, and the release picks the locale cookie, else Accept-Language. The edge's copy of the lobby was stored at 08:48:30. At 08:55:00 the on-call engineer purges the lobby there and changes nothing else. At 08:55:04 Amira (cookie locale=ar; Accept-Language: de-DE,de;q=0.9) opens it, the first request after the purge. From 08:55:06, 300 German candidates (no cookie; Accept-Language: de-DE) open it, one every 3 seconds.
How many of the 300 see the lobby in Arabic?
Question 7 of 7
A wallet's top-up page /wallet/top-up picks German, Arabic or English from the lang cookie, else from Accept-Language, and its CDN caches it with Cache-Control: public, s-maxage=600. After MR !3316 shipped this morning, customers who chose Arabic in the language picker report the page in German. The Frankfurt PoP held no copy until 09:10:00, when Lukas (lang=de) and then Mariam (lang=ar), both sending Accept-Language: de-DE, opened the page. At 09:13:02 the on-call engineer sends these four requests, each with Accept-Language: de-DE, two seconds apart, and reads:
through the PoP Cookie: lang=ar page German Content-Language: de X-Cache: HIT Age: 182
through the PoP Cookie: lang=de page German Content-Language: de X-Cache: HIT Age: 184
straight to the origin Cookie: lang=ar page German Content-Language: de no Age
straight to the origin Cookie: lang=de page German Content-Language: de no Age
Which cause do these four answers support?
Go deeper
Pro tips
- Ask "what is the cache key?" first on any "wrong content for some users" report (the curriculum row's own tip). Then list what the page reads; anything it reads that the key lacks is the leak. When: the first minute of the page. Status: EXECUTED (model).
- Read
Agebefore blaming the origin. A wrong answer with anAgecame from a stored copy; the same request straight to the origin tells you what the release renders now. When: step 4, before reading any negotiation code. Status: EXECUTED (model).
Source
HTTP caching standards change slowly; CDN defaults change often: the facts below are as of 2026-10-06. No item is keyed on a source; every key is executed. Valid as of 2026-10-06.
- RFC 9110, HTTP Semantics, §12.5.5 Vary: https://www.rfc-editor.org/rfc/rfc9110.html (
rfc9110.txt), the curriculum row's first source. Quoted: Vary tells caches they "MUST NOT use this response to satisfy a later request unless the later request has the same values for the listed header fields as the original request"; "In other words, Vary expands the cache key required to match a new request to the stored cache entry." Supports steps 5–7. - Quoted: "it is also possible to store redirects, negative results (e.g., 404 (Not Found)), incomplete results"; header fields match if one can be transformed to the other by "normalizing both header field values in a way that is known to have identical semantics". Supports steps 5 and 11, and the edge model.
- Next.js, Internationalization guide: https://nextjs.org/docs/app/guides/internationalization (
nextjs-i18n.txt), the row's third source. Quoted: "It’s recommended to use the user’s language preferences in the browser to select which locale to use."; "Routing can be internationalized by either the sub-path (/fr/products) or domain (my-site.fr/products)." Its proxy example negotiates withnegotiatorand@formatjs/intl-localematcher, whichlib/origin.mjsruns. Supports steps 9 and 11. - Cloudflare docs, Origin Cache Control: https://developers.cloudflare.com/cache/concepts/cache-control/ (
cloudflare-cache-control.txt). Quoted: "By default, Cloudflare does not consider vary values in caching decisions." Supports step 8. - Amazon CloudFront Developer Guide, Cache content based on request headers: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/header-caching.html (
cloudfront-headers.txt). Quoted: "By default, CloudFront doesn't consider headers when caching your objects in edge locations."; more header values mean CloudFront "forwards more requests to your origin server for the same object." Supports steps 5 and 8. - Fastly documentation, Vary: https://www.fastly.com/documentation/reference/http/http-headers/Vary/ (
fastly-vary.txt). Quoted: "Vary is an HTTP standard, and Fastly supports it per spec". Fastly blog, Best practices for using the Vary header: https://www.fastly.com/blog/best-practices-using-vary-header (fastly-vary-blog.txt). Quoted: "you don't want to Vary on a header that has a lot of variations without normalization."; "Cookie is probably one of the most unique request headers, and is therefore very bad." Supports steps 5, 7 and 8. http-cache-semanticsREADME: https://raw.githubusercontent.com/kornelski/http-cache-semantics/main/README.md (hcs-readme.txt). The library behind the edge model (RFC 9111 rules for a shared cache,shared: true).- Unused cache defaults (the Cloudflare default-behaviour page,
cloudflare-default.txt) are saved and key nothing.
Related lessons
Checked against the tools and docs as of 2026-10-06.