Edge Localization and Geo-Routing for PADI Properties
Proposed
Date
2026-08-11 · v2
Deciders
To be confirmed
Consulted
SEO, Legal, Commerce
Informed
Property owners
01
Context & Problem
PADI runs several websites on different technology stacks: padi.com on Next.js, store.padi.com, club.padi.com, some Drupal sites, some Vue and React apps, and country domains such as padi.fr. Each one decides on its own what language, currency, and units to show a visitor, and some do not decide at all. Together they serve 2 to 5 million visitors a month.
Units matter more than they look. Dive content uses metres or feet, bar or psi. Showing the wrong one is a correctness problem in teaching material, not a formatting preference.
Two facts shape the design. First, every supported language is available in every region. A region decides which language shows by default. It never limits which languages a visitor can pick. Second, we do not vary by country. There are about 250 of them, which is far too many to cache well, and most differ from their neighbours in no way that matters here. A region is a group of countries that share one currency, one unit system, and one default language.
The two obvious ways to build this are both bad. Calling a localization API on every page load adds a round trip to every request and puts one point of failure in front of every PADI site. Letting each site work out geography for itself means they drift apart: three definitions of which region Belgium belongs to, three currency tables, three sets of bugs.
How do we work out a visitor's region and language once, at the edge, without slowing anything down or adding a dependency — and then give that answer to sites that are built differently and deploy separately?
02
Options & Decision
Rejected
Option A
Each site does its own
Every stack writes its own geo detection. The sites drift apart, and each team repeats the same caching and SEO mistakes.
Chosen
Option B
One shared decision at the Cloudflare edge
A single Worker with no dependencies works out the answer and passes it on through a versioned contract.
Rejected
Option C
A central localization API
Every site calls a service on each request. That is a round trip and a hard dependency on every page load across every site. See D7.
Chosen — Option B
One shared decision at the Cloudflare edge. Cloudflare already handles TLS for these sites, so the work happens inside a step that already exists. Nothing new is added to the request path, nothing new can fail, and the code stays portable to a different edge platform for China.
This is a function and a contract, not a service. decide(signals, config) reads the request and a config file that ships with the code. Sites get the answer as request headers, as a JSON block in the page, or from a JSON endpoint.
03
Architecture
04
Region Model & Configuration
About 250 countries group into 12 to 15 regions. A region carries one currency, one unit system, and one default language. It is all one JSON file with a version number, published to npm and Composer so every stack uses the same names.
Several countries can share one region. Monaco uses France. A country we have not listed falls back to default. Most countries should not have a region of their own.
The language list is global, not per region. Each region only names its defaultLanguage. The per-region languages list is there for real exceptions such as padi.cn; if a region does not have one, it serves every language. Matching against Accept-Language follows BCP-47, so zh-Hans and zh-Hant stay separate, and so do pt-BR and pt-PT.
Units belong to the region, which is how we describe the metric and imperial split in one place.
CI checks the file on every change, which is what makes it safe for people outside the platform team to edit. It checks that the file matches the schema; that every ISO country code is either mapped or listed as deliberately left on the default; that each defaultLanguage is one the region actually serves; that language tags and currency codes are valid; that each dedicated domain belongs to exactly one region; and that every region-and-language page is one the redirect rules will leave alone.
Still open: the United Kingdom. PADI teaches in metric, but UK consumers often think in feet. The file allows one answer per region, so it cannot say "metric for course material, imperial for marketing". If we need that split it belongs in the content, not here. Someone who knows the UK audience has to decide before launch.
05
URL Format
Every real page URL names both the region and the language. A URL with only the region, such as /be, cannot be a page: two visitors who want different languages would share one cached copy and one search result.
A link that names a region but no language is not redirected. It renders in that region's default language with a canonical tag pointing at the proper URL, so old links and hand-typed addresses keep working without adding another kind of redirect.
06
What We Check, In Order
Every step except 3 happens inside the Worker with no network calls. Step 3 is the only one that reads from a database, and it is deliberately the only one that does not run at the edge. That is decision D7, and it is why this service has no dependencies.
07
Redirect Rules
Five teams will read this, so it is written as a rule rather than a description. Anything vague here becomes a different bug on each site.
SITE ROOT — redirect only when all five are true
the visitor is not a bot
the URL has no region
the path is exactly "/"
the method is GET or HEAD
the URL has no ?noredirect
then 302 to /{language}-{region}/
using the region and the language we worked out
REGION ROOT — redirect only when all six are true
the visitor is not a bot
the URL has a region
the URL has no language
the path is exactly that region root, nothing after it
the method is GET or HEAD
the URL has no ?noredirect
then 302 to /{language}-{region}/
keeping the region from the URL, adding the language we worked out
Three details are easy to miss and expensive to miss. Check the method, because a 302 on a POST quietly throws away the body. Use 302 and not 301, because browsers and proxies cache a permanent redirect and it is very hard to take back. And do it in one step: going from / to /be to /nl-be/ would charge the visitor twice for the arrival cost in section 11, for nothing. ?__geo=XX forces a country outside production; in production it needs a signed value with an expiry.
08
What decide() Returns
This is the contract every site codes against. It is the same in all three transports.
Field
Type
Comes from
What it means
region
enum
worked out
The region id. Everything else follows from it
country
ISO 3166-1 alpha-2
cf.country
XX if unknown. For information only — do not make decisions from it
language
BCP-47
URL, then Accept-Language, then the region default
Always one of the supported languages, or one the region allows
locale
BCP-47
language + region
Pass straight to Intl.*
currency
ISO 4217
region
What to display, nothing more. Never use it to charge someone
units.*
system, depth, pressure, temperature
region
metric or imperial, giving m or ft, bar or psi, C or F. Getting this wrong in course material is a correctness bug
timezone
IANA
cf.timezone
A good guess. Do not use it for anything legal
signalSource
url | session | geo | default
see section 06
Which step produced the answer. Without this we cannot explain why a visitor ended up somewhere
confidence
high | low
cf.asn
low when the country is unknown or the request comes from a VPN or a datacentre. These visitors are invisible in the averages
isBot
boolean
D9
Turns off redirects completely
configVersion
string
config
Lets a site tell which version of the config produced this
09
How Sites Receive the Answer
Three ways to receive it, all carrying the same data: x-padi-* request headers; a JSON block added to <head> after the cache read; and GET /_padi/locale for clients with no Worker in front of them.
Site
How it reads the answer
The thing that will catch you out
padi.comNext.js
Headers plus the [lang]-[region] URL part
Do not call headers() in the root layout. It quietly turns the whole app dynamic and throws away the caching benefit. Add a lint rule
store.padi.com
Headers, as a suggestion
Currency here is only for display. Work out the real price on the server, and lock the region to the cart when it is created. Never cache logged-in pages at the edge
club.padi.comSPA
The JSON block
Read it as the app starts, so there is no extra request and no flash of the wrong currency
Drupal sites
Headers plus a cache context
Turn off Drupal's own geolocation modules, and add a cache context on the header or the page cache will serve one region's pages to another
Apps and embeds
The JSON endpoint
No website may call this endpoint while rendering. That puts back the round trip this design removes. Block it in review
Two rules keep this workable as sites deploy on their own schedules. Only add, never change: headers are never removed or reused, and x-padi-config-version says which version arrived. Every site works without us: if the headers are missing, show the worldwide region rather than an error. The Worker is an improvement, never a requirement.
Only trust these headers from Cloudflare. Use Authenticated Origin Pulls or a shared secret. The Worker removes any x-padi-* headers a visitor sends, but that does nothing if someone can reach the origin server directly. This matters most for the store.
10
Design Decisions
Everything above describes what the service does. These eleven decisions say why it is built that way, and this is where most of the risk sits. Each one names the alternative we rejected, so a future reader can tell whether something has changed enough to reopen it.
D1Ship the config with the Worker instead of reading it from KV
Why
The country table is a few kilobytes and changes a few times a year. Shipped with the code, it loads once when the Worker starts and then costs nothing. Reading it from KV would add 1 to 10 ms per request plus a charge per read. This design is fast because it makes no network calls, so we should not add one here.
Cost
Changing a region needs a deploy. That takes seconds, and it means every change goes through code review.
D2Vary by region, not by country
Why
Currency and units change from one region to the next, and language multiplies that. Varying by country would cache thousands of versions of every page, almost none of them used often enough to stay warm. Varying by language alone could not show the right currency or units. Twelve to fifteen regions with the full set of languages gives a few hundred versions. Most traffic lands on a handful, and Tiered Cache covers the rest everywhere except China, which does not support it.
Cost
Some countries end up in a region that does not fit them well. Monaco gets France, and the switcher is how a visitor fixes that. Rare combinations such as Thai in Belgium will almost never be in cache. Those pages exist so they are correct and can be indexed, not because we expect traffic.
D3Put the region and language in the URL
Why
Everything else depends on this. Enterprise does give us custom cache keys, so this is a real choice, not a workaround. We still use the URL, because a cache key cannot do three things. Search engines can only rank a version that has its own address. A URL can be shared and bookmarked, which is what replaces the cookie. And a cache key does not travel to another edge platform. A custom key would split the cache the same way regardless.
Cost
All five stacks have to follow the same URL convention, and the site root stops being a page and becomes a redirect.
D4Only redirect from the site root and the region roots
Why
We cannot use cookies, so we cannot tell whether someone has been here before; the rule has to work from the URL alone. Redirecting deep links would break inbound links, campaign links, and anything shared — the traffic we care most about. Redirecting crawlers could stop localized pages being indexed at all. A root-only rule runs once, when someone arrives.
Cost
A visitor who is not logged in gets redirected from the root every time they come back. Logged-in visitors keep their choice through D7.
D5Add the per-visitor part after reading from cache
Why
The banner needs data about this particular visitor, but we want the page fully cached. Doing it after the cache read gives us both: the cached page stays the same for everyone, and the visitor's part is added as the response streams past. Fetching it from the browser can be blocked by extensions, and building it at the origin means the page cannot be cached at all.
Cost
The Worker has to sit on the response as well as the request.
D6Use separate x-padi-* headers rather than one packed header
Why
Five different stacks read this. Separate headers show up in curl and in browser dev tools, can be used in Cloudflare rules and origin logs, and need no decoding in PHP. Packing everything into one encoded header would solve a problem we do not have.
Cost
The list could grow. Stop at about ten headers; after that, add one JSON header and keep separate headers for the fields used most.
D7Send a saved preference inside the session token instead of looking it up
Why
This is what keeps the service free of dependencies. Calling the preference service on every request would make its uptime the ceiling for every PADI site at once, and would add 50 to 300 ms to every request to help the few people who have saved anything. A region in the session token means the edge checks a signature and reads a field.
Cost
The value is out of date until the token refreshes, and the login service has to write it. That only means one wrong redirect, which the site corrects on arrival.
D8Every language is available everywhere; the region only sets the default
Why
Every supported language ships in every region, so language and region are independent. The region decides currency, units, and which language appears first. The language comes from the URL, then Accept-Language, then the region default. One rule keeps this coherent: changing the language never changes currency or units. Only changing the region does. Someone in France reading Japanese gets Japanese text at euro prices.
Cost
We now cache and index a few hundred region-and-language combinations. Section 13 covers what that costs in caching and hreflang. padi.cn is the one region expected to limit its languages, and the config has an override for that.
D9When unsure, treat a visitor as a bot
Why
Getting this wrong one way is cheap and the other way is expensive. Treat a person as a bot and they see the global site and can use the switcher. Treat a crawler as a person and we redirect it, which can stop localized pages being indexed at all. We cannot verify crawlers by reverse DNS, because that is a network call.
Cost
Once Bot Management is live, verifiedBot and the bot score become the main signal and the user-agent list drops to a fallback. Bot Management runs only some of its models on the China network, so China stays more dependent on the list.
D10Never break the page
Why
A localization bug must not be able to take padi.com down. If anything throws an error, the request passes through untouched and the visitor gets the normal site without the extra behaviour. That is what makes it safe to put this in front of live traffic.
Cost
When it breaks, nothing looks broken. The fallback counter and its alert are the only warning we get.
D11Two edge platforms, one shared function
Why
Both original blockers are gone: we have Enterprise, and Workers do run on the China Network. What remains is that it is a separate subscription, gated behind an ICP filing and JD Cloud vetting, and it has no Tiered Cache. Until we commit to it, a core with no network calls also runs on Alibaba EdgeRoutine or Tencent EdgeOne behind a small adapter.
Cost
Two deploy pipelines and tests against both, for as long as we keep the option open. Taking the China Network subscription collapses this to one platform — see section 16.
11
Speed & Cost
Cloudflare already handles TLS for these sites, so the Worker runs inside something that is already happening. It adds no extra network hop.
What
Adds
Note
Starting up, reading request.cf
0–5 ms
Usually zero. A cold start happens during the TLS handshake, so the visitor does not feel it
decide()
< 0.1 ms
Lookups in an object already in memory
Session check and page rewrite
0.2–1.5 ms
Session check only for logged-in visitors. The rewrite streams, and the head is in the first chunk, so time to first byte is unaffected
Total, normal case
< 1 ms
About 0.1% of a typical request
Fetching from the origin on a cache missfor comparison
50–500 ms
What caching at the edge removes
Redirect to a region on the same domain
20–80 ms
Same connection, reused
Redirect to a country domain
100–300 ms
A new DNS lookup and a new TLS handshake. This is the biggest cost in the design
The redirect is the cost, not the Worker, and it only happens when someone arrives at a root. The gap between the last two rows is a reason to prefer paths over country domains, which points the same way as D3. The site should get faster overall. This design pushes us to make HTML cacheable at the edge, which saves 200 to 800 ms on a cache hit. That only works if the origins send cacheable headers, and Next.js does not by default. These are estimates, not measurements.
Traffic scenario
Worker requests / month
CPU-ms
Low — 2 M visitors, 4 pages each
~9.5 M
~4 M
Expected — 3.5 M visitors, 5 pages each
~20 M
~8 M
High — 5 M visitors, 6 pages each
~35 M
~14 M
If we forget to skip images and scripts
~200 M
~10 M
Workers are covered by our Enterprise agreement rather than the public per-request plan, so what needs checking is volume against the contracted allowance, not list prices. The reason to skip images and scripts at route level is the twenty-fold jump in the last row — headroom and blast radius, not the bill. Cost should not drive any decision here. Cache hit rate and search indexing are where the real money is. Tiered Cache is included with Enterprise. The one commitment that still costs real money is the Cloudflare China Network, a separate subscription on top of Enterprise (D11).
12
What Happens When It Breaks
D10 says nothing may break the page. That is only reassuring if we say exactly what the degraded version looks like.
Problem
How we find out
What happens
decide() or the page rewrite throws
The fallback_total counter
The request goes through untouched. No banner, page otherwise fine
The config file fails validation
CI
The deploy is blocked. Nothing reaches production
The config is valid but wrong
Golden tests, then real-user data per region
Those countries get the wrong region. Limited to them, and we fix it by redeploying the previous version
The session token is invalid or expired
The catch around the check
Falls back to the visitor's location. They may get a redirect they would otherwise have skipped
The origin returns a 5xx
—
Passed through as-is. No different from today
A redirect loop
A CI check and a synthetic test
Must be impossible by design. If it happened, that whole region would be down
Cloudflare is down
Cloudflare status
Everything is down — but that is true today too, since Cloudflare already fronts these sites
A redirect loop is the one new way this design can break.padi.fr/ is meant to redirect. If the code fails to spot the language in the path, padi.fr/fr/ looks like a bare region root too and redirects to itself. Same on the shared domain if /nl-be/ is not recognised. The fix is structural: read the region from the domain as well as the path, and have CI check that the rules leave every region-and-language page alone, and that every redirect reaches one in a single step.
13
Risks
HighIf a redirect ever gets cached, everyone served by that Cloudflare location goes to the wrong region.Put no-store on every redirect, audit the Cache Rules, and add a test that checks the root is never served from cache.
HighThe site root becomes a redirect, which changes what ranks on our strongest URL.Crawlers get a normal page with x-default; people get a redirect. That is defensible, but one address now answers two ways. SEO needs to sign this off properly, not glance at a diagram.
HighChina depends on an ICP filing, not on engineering.It needs a Chinese legal entity, JD Cloud content vetting, and usually four weeks or more. Workers do run on the China Network, but it is a separate subscription and has no Tiered Cache, so that region will run colder than the rest.
MediumSeveral hundred versions of each page, and too many hreflang tags to fit in the head.Most traffic hits a few of them and the rest stay cold on purpose. Tiered Cache wins back a lot everywhere except China, which does not support it, and we watch the hit rate per version. Publish hreflang through XML sitemaps built from regions.json so the two cannot drift apart.
MediumWithout cookies, a visitor who is not logged in is redirected from the root every single visit.They have to bookmark the region URL or use the banner each time. Logged-in visitors are fine through D7.
MediumThe sites drift apart, or work out geography for themselves anyway.Drupal geolocation modules and framework language detection will fight the edge and give different answers. Find them and turn them off site by site during rollout.
LowVPN, corporate proxy, and satellite users are put in the wrong place.Location data is right about 99% of the time overall, but consistently wrong for these. confidence flags them, and the switcher is the way out. Never trap someone in a region.
LowAn IP address is personal data under GDPR, and the banner can shift the page as it loads.Log the region we worked out rather than the IP, check the session-token approach with legal, and leave space for the banner so nothing jumps.
14
Monitoring, Testing & Rollout
Because nothing ever looks broken, monitoring is not optional here. It is the only way we find out.
What we watch
Why
Alert
Fallback ratefallback_total ÷ requests
The most important number. Anything above zero means D10 is catching errors and something is broken quietly
above 0.1%
Decision log1 in 100 requests
region, country, language, signalSource, confidence, isBot, whether we redirected, config version, location. Never the IP address
—
Share of traffic per region
The quickest way to spot a bad config. A sudden shift means a country is mapped wrong
—
How often we redirect
A jump means the bot list or the config has regressed
±20% week on week
Worker CPU, 99th percentile
Above 2 ms suggests the rewrite is running on unusually large pages
above 2 ms
Cache hit rateper region and language
An average hides one popular version missing cache every time
—
Testing, most useful first:
A fixture listing all ~250 country codes and the region each should get. When someone edits the config, the diff on this file is what gets reviewed, which is what makes it safe for people outside the platform team to change it.
Property tests: whatever the inputs, the language we return is always one we support, and changing the language never changes the region, currency, or units.
A loop check: for every region-and-language page the rules leave it alone, and every redirect lands on one of those pages in a single step.
A contract test on each site, with all headers present, all headers missing, and an unknown region. The missing-headers case is the one that actually breaks in production.
Synthetic checks from several places: the right redirect from each, no-store on every redirect, complete hreflang in the sitemaps, and a second request served from cache.
Rollout goes in four stages, and none of them changes behaviour at the same time as adding something. Stage 0, two weeks: the Worker works out the answer, logs it, and sets the headers. No redirects, no site reads them. Visitors see no difference, and the traffic-per-region numbers are the cheapest place to catch a country mapped wrong. Stage 1: sites start reading the headers and the banner appears, each on its own schedule. Stage 2: turn on redirects, one region at a time. Stage 3: serve China from inside China, once the ICP filing is done. Start that filing now, because nothing in engineering makes it faster.
Beyond those tests, we will know it works when:
The Worker only runs on pages. Images and scripts do not show up in the request counts, and 99th percentile CPU stays under 2 ms in production.
The site root is never served from cache, and a POST to it is never redirected.
Search engines get a normal page at the site root and at each region root, and no version is left out of the index.
Every site still renders the worldwide region correctly with all x-padi-* headers missing, and origins reject those headers unless they come from Cloudflare.
?__geo=FR behaves the same locally as in production.
Someone has actually used the kill switch and it restored the old behaviour without a deploy.
A logged-in visitor whose saved region differs from their location lands in the right place, with no database read at the edge.
Over two weeks, real-user data shows time to first byte improving per region on cache hits, with no drop in LCP for people arriving at a root.
Turning it off must not need a deploy. An environment variable, or removing the route, puts everything back within seconds. Try it before stage 2 — a kill switch nobody has tested is not a kill switch.
15
What We Get, and What It Costs
GoodOne definition of region, currency, units, and language for every site, changed through code review, with nothing new in the request path and nothing new that can fail.
GoodPages become cacheable at the edge, and each language gets its own URL — so every version can be cached, indexed, shared, and remembered without storing anything on the visitor's device.
BadVisitors who are not logged in get redirected from the root every visit, and we cannot remember when they chose otherwise.
BadEvery site has to adopt the same names and the same URL format, and adding a region now needs a deploy and a loop check.
NeutralWe will run two edge platforms sharing one function. The store keeps full control of pricing and tax; this layer only suggests what to display.
16
Open Questions
Does our country-to-region grouping match the country/region sheet? If the sheet's regions are the same grouping we need, they should seed this list. If they are coarser — closer to PADI's operating entities — then they are a different thing and this service does not model them.
What is the default language for each region that has more than one? Belgium — Dutch, French, or English — is a business decision, and the config only allows one answer.
Is padi.cn really the only region that limits its languages?
Are PADI sessions JWTs we can check at the edge, or opaque ids? This decides between D7 as written and correcting things at the origin instead.
Is the no-cookie rule a legal requirement or a policy choice? A cookie that only gets set when someone actively chooses is normally exempt from ePrivacy consent, and localStorage falls under the same rule. If it is policy, we may be giving up useful behaviour for no legal benefit.
Which regions use country domains and which use paths, and where is that still open? Paths are faster to arrive at (section 11) and keep SEO strength in one place (D3).
Do we take the China Network subscription? Workers run there and we already have Enterprise, but it is a separate subscription with an ICP filing, JD Cloud vetting, and no Tiered Cache. Taking it reduces D11 to one edge platform; not taking it means Alibaba or Tencent for cn.
Is the UK metric or imperial for consumer-facing content?
Where this stops. Translations and message files stay with the i18n layer — next-intl and its equivalents. This service decides which language and region; the i18n layer does the rendering. Anything downstream of the region — contact details, legal text, which courses are on sale — is the site's own business. This service does not model PADI's operating entities and does not need to.
A
Appendix — Sites Not Behind Cloudflare
The Worker is how we deliver this, not what it is. Splitting the work in two lets sites Cloudflare does not sit in front of still get the right answer. This is the same split D11 uses for China:
resolveCountry(request) returns a two-letter country code // differs per platform
decide(signals, config) returns the answer // identical everywhere
Only the country lookup differs by platform: cf.country on Cloudflare, CloudFront-Viewer-Country on CloudFront, X-Akamai-Edgescape on Akamai, geo.country_code on Fastly, or a local MaxMind-style database on a plain server. Everything after that is shared.
Tier
Situation
How
Cost
What it gives up
1
Behind the Worker
Headers and the JSON block
< 1 ms
Nothing
2
Own server, another CDN or none
Its own geo header into the shared decide()
0–80 ms
Edge caching and redirects
3
SaaS with no server access
padi-locale.js in the browser, or tagged links
one request
Correct server rendering, and SEO
4
No web request at all
Read the region already stored on the record
—
Nothing, as long as it never recalculates
Watch out: a server calling us sends its own IP, not the visitor's. A backend that calls /_padi/locale gets its own datacentre's location back, and the answer looks perfectly reasonable. The fix is for the caller to send the country its own edge already worked out, rather than sending an IP address.
Tier 3 works because the browser goes through Cloudflare even when the page did not. The script reads ?padi_locale=nl-be off the incoming link and only calls the endpoint for direct arrivals. That endpoint must be no-store: one URL, but the answer depends on who is asking, so caching it hands the first visitor's country to everybody at that location. Client-side is the ceiling — currency labels, units, and the switcher, never the whole page language.
Tier 4 uses the region already stored and never works it out again. A confirmation email sent three days later from a different datacentre must arrive in the language the customer actually bought in.
If we control the DNS, put the site behind Cloudflare and it becomes tier 1, which is easier than building a tier 2 integration. Keeping the tiers consistent is the real work: publish decide() to npm and Composer, test once per tier rather than once per site, and run a nightly check asking every site which region it thinks it is in.