Design Document PADI Web Platform

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.

regions.json { "version": "2026-08-11", "default": "ww", "languages": ["en", "es", "fr", "de", "it", "nl", "pt-BR", "ja", "zh-Hans", "zh-Hant", "ko", "th", ...], // the global set "regions": { "ww": { "host": "padi.com", "defaultLanguage": "en", "currency": "USD", "units": "metric" }, "us": { "host": "padi.com", "defaultLanguage": "en", "currency": "USD", "units": "imperial" }, "fr": { "host": "padi.fr", "defaultLanguage": "fr", "currency": "EUR", "units": "metric" }, "be": { "host": "padi.com", "defaultLanguage": "nl", "currency": "EUR", "units": "metric" }, "cn": { "host": "padi.cn", "defaultLanguage": "zh-Hans", "languages": ["zh-Hans"], // the one exception "currency": "CNY", "units": "metric" } }, "countries": { "FR": "fr", "MC": "fr", "BE": "be", "US": "us", "CN": "cn", ... } }

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.

FieldTypeComes fromWhat it means
regionenumworked outThe region id. Everything else follows from it
countryISO 3166-1 alpha-2cf.countryXX if unknown. For information only — do not make decisions from it
languageBCP-47URL, then Accept-Language, then the region defaultAlways one of the supported languages, or one the region allows
localeBCP-47language + regionPass straight to Intl.*
currencyISO 4217regionWhat to display, nothing more. Never use it to charge someone
units.*system, depth, pressure, temperatureregionmetric or imperial, giving m or ft, bar or psi, C or F. Getting this wrong in course material is a correctness bug
timezoneIANAcf.timezoneA good guess. Do not use it for anything legal
signalSourceurl | session | geo | defaultsee section 06Which step produced the answer. Without this we cannot explain why a visitor ended up somewhere
confidencehigh | lowcf.asnlow when the country is unknown or the request comes from a VPN or a datacentre. These visitors are invisible in the averages
isBotbooleanD9Turns off redirects completely
configVersionstringconfigLets 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.

SiteHow it reads the answerThe thing that will catch you out
padi.comNext.jsHeaders plus the [lang]-[region] URL partDo 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.comHeaders, as a suggestionCurrency 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.comSPAThe JSON blockRead it as the app starts, so there is no extra request and no flash of the wrong currency
Drupal sitesHeaders plus a cache contextTurn 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 embedsThe JSON endpointNo 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.

WhatAddsNote
Starting up, reading request.cf0–5 msUsually zero. A cold start happens during the TLS handshake, so the visitor does not feel it
decide()< 0.1 msLookups in an object already in memory
Session check and page rewrite0.2–1.5 msSession 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 msAbout 0.1% of a typical request
Fetching from the origin on a cache missfor comparison50–500 msWhat caching at the edge removes
Redirect to a region on the same domain20–80 msSame connection, reused
Redirect to a country domain100–300 msA 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 scenarioWorker requests / monthCPU-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.

ProblemHow we find outWhat happens
decide() or the page rewrite throwsThe fallback_total counterThe request goes through untouched. No banner, page otherwise fine
The config file fails validationCIThe deploy is blocked. Nothing reaches production
The config is valid but wrongGolden tests, then real-user data per regionThose countries get the wrong region. Limited to them, and we fix it by redeploying the previous version
The session token is invalid or expiredThe catch around the checkFalls 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 loopA CI check and a synthetic testMust be impossible by design. If it happened, that whole region would be down
Cloudflare is downCloudflare statusEverything 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

14

Monitoring, Testing & Rollout

Because nothing ever looks broken, monitoring is not optional here. It is the only way we find out.

What we watchWhyAlert
Fallback ratefallback_total ÷ requestsThe most important number. Anything above zero means D10 is catching errors and something is broken quietlyabove 0.1%
Decision log1 in 100 requestsregion, country, language, signalSource, confidence, isBot, whether we redirected, config version, location. Never the IP address—
Share of traffic per regionThe quickest way to spot a bad config. A sudden shift means a country is mapped wrong—
How often we redirectA jump means the bot list or the config has regressed±20% week on week
Worker CPU, 99th percentileAbove 2 ms suggests the rewrite is running on unusually large pagesabove 2 ms
Cache hit rateper region and languageAn average hides one popular version missing cache every time—

Testing, most useful first:

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:

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

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.

TierSituationHowCostWhat it gives up
1Behind the WorkerHeaders and the JSON block< 1 msNothing
2Own server, another CDN or noneIts own geo header into the shared decide()0–80 msEdge caching and redirects
3SaaS with no server accesspadi-locale.js in the browser, or tagged linksone requestCorrect server rendering, and SEO
4No web request at allRead 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.