CDN assessment
Last reviewed 29 Aug 2026. Browser-side figures re-measured against the live buckets on 28 Aug, after the release rollout completed across the
finmatch-pfleet. This page went stale once between 24 and 27 August because the work it recommended landed while it still read as a proposal — if you are reading it more than a month after the date above, re-run the measurements in section 1 and 3 before acting on it. The one figure that cannot be re-measured from a browser is the GB/day egress reduction; it was pulled from Cloud Monitoring on 29 Aug (~66%, ~140 GB/day — see the measured figures below), and the query to re-run it is inegress-operations-runbook.md.
In plain English (read this first)
Think of our files as tools in a warehouse. Every time a shopper's browser needs one, it walks to the warehouse and carries back a copy — and we pay for every walk. That is the egress bill.
The fix already live did not touch the warehouse at all: we now let each visitor keep the tool they collected, so a returning shopper walks zero times. That is what cut the bill this month, and it needed no new infrastructure.
A CDN is like hiring a helper who stands near the shoppers with a small stock of the popular tools, so most requests get a quick hand-off instead of a full walk. A hand-off is cheaper than a walk, so in principle it saves money.
Three things decide whether hiring that helper is actually worth it:
- The helper is not free. It costs money just to be stationed there. If only a few real walks happen, the standing cost can be more than the walks it saves.
- We already removed most of the walks for free. Returning shoppers no longer walk at all. So the real question is not "walks vs. hand-offs" — it is how many unavoidable walks are still left. That number has now been measured (29 Aug 2026): the free browser-cache fix cut egress by about 66% — roughly 140 GB a day — and, tellingly, the number of requests barely fell while the bytes collapsed, which means the expensive repeat downloads of the big files are already gone. The walks that are left are small and cheap. See the measured figures later in this document.
- A per-merchant labelling problem would blunt the helper. Our files are currently served with per-merchant CORS labels (see below). A helper can only re-use a tool for the exact merchant it is labelled for, so today it would end up walking almost as much as if it were not there. That is why "fix the labelling" is step one of any CDN project — not a saving on its own, but the thing that makes the helper worth hiring.
So the honest position, now that the traffic number is in: the free browser-cache fix has already banked the easy win, and the walks it left behind are small and cheap. A CDN would mostly re-cache files the browser already caches, and it is blunted by the per-merchant labelling on top of that — so the answer is not worth doing, not merely "maybe". The rest of this page is the engineering detail behind that conclusion.
Status: still an assessment, but the ground under it has moved. No CDN is
implemented. Everything this document listed as a prerequisite has now shipped:
versioned immutable releases have rolled out across the finmatch-p fleet (the
paced assignment loop completed on 28 Aug), the admin-written config objects
carry year-long immutable headers — re-confirmed live on lender-styling.json,
lender-overrides.json and finmatch-rate-card.json on 28 Aug — and the egress
baseline it asked for has been measured.
Measurements were originally taken on 24 Aug 2026 and re-taken on 27 Aug 2026; the figures below are the later set, and the differences are called out where they matter.
The short version has changed. The mechanism to adopt a CDN safely exists and has been exercised in production. But the work done since has absorbed most of the traffic a CDN would have served, and the CORS fragmentation blocker turned out to be 3.3× worse than first assessed. Both push the case the same way: a CDN is now a smaller win than it looked in August.
1. There is no CDN under our control, but GCS is not purely origin-read
The 24 Aug assessment recorded no age header on any response and concluded
that every request was an origin read. Re-measuring on 27 Aug, six consecutive
requests to each of five objects spanning every cache policy we use:
release script (immutable) age: 33 34 35 38 37 none
shared rate card (immutable) age: none none 2 none 5 3
SDK (max-age=300) age: none none 3 4 5 6
env script (max-age=900) age: none none none none 7 8
ecom config (max-age=3600) age: 0 none 3 4 3 none
age now appears intermittently on all of them. It is not a property of the
immutable header — short-cache objects show it too — so this is GCS's own
serving layer, not something the cache policy earned us.
What follows from that is narrower than the original claim: some fraction of requests are already served without a full origin read, that fraction is not observable to us, and it is not something we control or can tune. A CDN would still convert repeat requests into edge hits we can measure and reason about. But "every request is an origin read" overstated the starting position and therefore overstated the win.
2. Adoption is now a pointer change, not a merchant change
This is the part that changed with Step 3. The SDK no longer computes asset
URLs from the environment name; it takes an absolute baseUrl from the
resolver's runtime block. So putting merchants behind a CDN hostname is:
PUT /admin/runtime-channels/live
{ "version": "p-20260824-2237-301e047d",
"baseUrl": "https://assets.finmatch.io/releases/p-20260824-2237-301e047d" }
Consequences worth being explicit about, because they are what make this low-risk:
- Per-merchant canary.
runtimeChannelis set per merchant, so one merchant can be moved to a CDN-backed channel while everyone else stays on the bucket. - Instant rollback with no deploy.
runtimeKillSwitch: trueon a merchant, or deleting the channel pointer, returns them to direct-to-bucket URLs on the next resolver response — roughly 30 seconds. - Automatic client-side fallback. If the CDN hostname fails or returns errors, a critical script failure demotes that page load to the env bucket by itself. Tested: all 16 scripts recover with zero redirects.
- No burned-in URL changes. The snippet and the SDK URL are untouched.
Without the resolver, adopting a CDN would have meant changing URLs the SDK computes and waiting out cache windows to roll back. That was the real reason to park it until immutability landed.
3. The blocker: vary: Origin fragments the edge cache
GCS returns a per-origin Access-Control-Allow-Origin and declares
vary: Origin:
Origin=https://absolutemotocross.co.uk access-control-allow-origin: https://absolutemotocross.co.uk vary: Origin
Origin=https://greensquirrelenergy.co.uk access-control-allow-origin: https://greensquirrelenergy.co.uk vary: Origin
Origin=(none) (no ACAO) vary: Origin
A correct CDN must therefore include Origin in its cache key. Get that wrong
and the edge serves one merchant's ACAO header to another merchant's
shoppers, which fails CORS and breaks the storefront. Get it right and the
cache fragments: 305 distinct origins across 154 hosts are configured as of
28 Aug (up from 93 when this was first assessed), so an edge location can hold
up to 305 copies of finance-container.js rather than one. The blocker is 3.3×
worse than at first assessment, and it will keep growing with the merchant base.
The small dip from the 309 measured on 27 Aug is merchant churn, including the
DivideBuy retirement — the count is noisy at the margin but the order of
magnitude is the point.
That matters because module scripts are the biggest bytes and they are exactly what needs CORS:
| Asset | Needs CORS? | Why |
|---|---|---|
scripts/*.js (~450–550 KB) | yes | <script type="module"> is always fetched in CORS mode |
css/finmatch.css (133 KB) | no | plain <link rel="stylesheet">, no crossorigin |
| config / rate-card JSON | yes | fetch() |
Fragmentation does not make a CDN useless — a single merchant's repeat traffic still hits — but it multiplies cache-fill and dilutes the hit rate on the objects that carry the most bytes.
Two ways to clear it
Option 1 — wildcard CORS on immutable release objects (preferred).
Immutable public JavaScript has no meaningful reason to be origin-restricted:
anyone can fetch it server-side, and restriction protects nothing. Serving
release paths with Access-Control-Allow-Origin: * removes Vary from the
equation and gives one cache entry per object.
The catch: GCS bucket CORS is bucket-wide, not per-prefix. So this cannot
be done inside gs://finmatch-p, which must keep its per-origin policy for
the admin-written config objects. It requires the dedicated
gs://finmatch-releases bucket — Option A in the release proposal, which was
deferred precisely because it needs a merchant-api change and a CORS
reconcile.
So the CDN case is the argument that eventually promotes Option A. Sequencing
is unchanged: publish under finmatch-p/releases/ now, migrate to a dedicated
bucket when the CDN goes in, and because the base URL is resolver-supplied,
that migration is a pointer change.
Option 2 — normalise the headers at the edge. A Cloudflare Worker or a
Cloud CDN response-header policy can strip Vary: Origin and emit
ACAO: * for release paths only, leaving the bucket policy alone. Cheaper to
implement, but it puts correctness in edge configuration rather than in the
origin, and a misconfiguration is a broken storefront rather than a cache miss.
4. What a CDN cannot cover
The SDK entrypoint. It is storage.googleapis.com/finmatch-finance-marketing-assets/s/scripts/finmatch-sdk.js,
burned into every merchant's page, 120,653 bytes at max-age=300
(105.9 KB when first assessed — it has grown with the runtime work), so it is
refetched roughly every five minutes per active browser. Moving it behind a CDN
hostname means changing the snippet on merchant sites, which is merchant
outreach — the same blocker as retiring the legacy header-loader shims.
That also caps how much of the total win a CDN can deliver, and it interacts
with the shim caveat: the legacy shim appends v=${stored.version || Date.now()}
to the SDK URL, so any old snippet that never stored a version busts the SDK
cache on every page load. Until the shim population is counted, the SDK's real
cache-hit rate is unknown.
5. Candidate implementations
| Cloud CDN + backend bucket | Cloudflare | |
|---|---|---|
| Fit with existing stack | same GCP project as the buckets | a Cloudflare account and a Worker (cloudflare/finance-assistant-proxy) already exist |
| What it needs | external HTTPS load balancer, a hostname (e.g. assets.finmatch.io), TLS cert, backend bucket, cache policy | a proxied hostname in front of the bucket, cache rules |
| Header control | response-header policies | Workers — full control |
| Billing shape | cache egress priced below bucket internet egress, plus cache-fill | bandwidth included on the plan, subject to their terms for non-HTML assets |
No figures are quoted deliberately. Cache-hit ratios and the current egress bill are the two inputs that decide whether this pays for itself, and both need James's billing access. The measurement above establishes that today's hit ratio at the edge is zero, because there is no edge — that is the only cache-hit number in this document that is not a guess.
6. Sequence: where we actually got to
| Step | State |
|---|---|
| 1. Releases publish, immutable, nothing reads them | Done — #444, 25 Aug |
| 2. First channel pointer, one canary merchant | Done — M281700, 25 Aug; kill switch exercised against production 26 Aug |
| 3. Before/after egress baseline | Done — see below |
| 4. Decide Option 1 vs Option 2 for CORS fragmentation | Open — this is now the gate |
| 5. Widen per merchant | Done for the release rollout: 140 of 142, 26 Aug |
Step 3 was the gate and it has been cleared. Measured 26 Aug 2026, 00:00–09:00 UTC, against a clean 23–24 Aug baseline:
| Bucket | Baseline | After | Change |
|---|---|---|---|
finmatch-shared | 12.49 GB | 4.88 GB | −60.9% |
finmatch-p | 34.14 GB | 8.30 GB | −75.7% |
| Combined | 46.63 GB | 13.18 GB | −71.7% |
What that does to the CDN case
This is the part worth reading before spending money on a hostname.
The traffic a CDN would have absorbed was repeat visits — the same shopper
re-fetching the same scripts every 15 minutes because max-age=900 had expired.
That traffic is now gone, absorbed by the shopper's own browser cache: measured
on a live storefront, a returning visitor fetches 0 bytes and makes 0 network
round trips for scripts and CSS, against 20 round trips before.
What remains for a CDN to serve is cold visits and cache-fill after each release. That is real, but it is a much smaller slice than the −71.7% already banked, and it is the slice where a CDN's own cache-fill cost partly offsets the saving.
Measured, 29 Aug 2026 (the number this document said nobody had pulled).
Bucket egress and request counts, project finmatch-finance-mkt, comparing the
three clean pre-rollout days (22–24 Aug) with the three clean post-rollout days
(26–28 Aug):
| bucket | egress before | egress after | cut |
|---|---|---|---|
| finmatch-shared | ~62 GB/day | ~26 GB/day | ~58% |
| finmatch-p | ~150 GB/day | ~46 GB/day (still falling toward ~38) | ~70–75% |
| combined | ~212 GB/day | ~64–72 GB/day | ~66%, about 140 GB/day saved |
The summary above is derived from the raw daily pull below (Cloud Monitoring,
project finmatch-finance-mkt). The rollout landed between 25 and 26 Aug — the
25th is still at the old level and the 26th is the first full day on the new
one, which is why the 25th is excluded from both the "before" and "after"
averages. The 29th is a partial day (the window ended around 13:00 UTC), so it
is shown for completeness but also excluded from the averages.
gs://finmatch-shared
| day | GB out | requests | KB/req |
|---|---|---|---|
| 2026-08-22 | 59.66 | 772,610 | 75.4 |
| 2026-08-23 | 60.65 | 776,771 | 76.2 |
| 2026-08-24 | 64.69 | 826,025 | 76.5 |
| 2026-08-25 | 54.88 | 833,938 | 64.3 |
| 2026-08-26 | 22.75 | 491,213 | 45.2 |
| 2026-08-27 | 28.15 | 453,091 | 60.7 |
| 2026-08-28 | 27.30 | 451,454 | 59.1 |
| 2026-08-29 (partial) | 22.92 | 420,700 | 53.2 |
gs://finmatch-p
| day | GB out | requests | KB/req |
|---|---|---|---|
| 2026-08-22 | 150.71 | 1,292,093 | 113.9 |
| 2026-08-23 | 148.38 | 1,271,827 | 113.9 |
| 2026-08-24 | 151.30 | 1,330,425 | 111.1 |
| 2026-08-25 | 172.18 | 1,427,345 | 117.8 |
| 2026-08-26 | 56.51 | 1,189,878 | 46.4 |
| 2026-08-27 | 42.95 | 1,077,223 | 38.9 |
| 2026-08-28 | 38.45 | 891,206 | 42.1 |
| 2026-08-29 (partial) | 38.18 | 870,877 | 42.8 |
The decisive detail is that requests fell far less than bytes — finmatch-p egress dropped ~70% while its request count dropped only ~19%, and bytes-per-request roughly halved (112 → 42 KB; shared 76 → 55 KB). That is the fingerprint of browser caching removing the expensive repeat downloads of the large module scripts: the bytes-heavy traffic is gone, and what is left is a large number of small, cheap requests. Edge-caching those big shared scripts is exactly what a CDN is for — and it is exactly what browser caching has already done for free. The remaining ~64–72 GB/day is spread across ~1.35M small requests and is still CORS-fragmented across 305 origins, so a CDN could not even share the big objects across merchants without the CORS fix first.
Verdict, now evidenced rather than assumed: do not pursue a CDN. The walks
worth eliminating are already gone. Revisit only if traffic grows materially, or
if the remaining request count (Class B operation cost, separate from egress)
becomes significant — which the cost-measurement scripts under tools/ will
show.
A CDN is therefore no longer a way to recover a large known cost. It is a latency and resilience play with a modest egress dividend, competing for attention against work with clearer returns.
7. What is needed to proceed
Billing baseline— done. See section 6. Method and repeatable commands are in the Egress Operations Runbook.- A decision on Option 1 vs Option 2 for the CORS cache key, which determines whether the dedicated releases bucket is promoted now or later.
- A hostname —
assets.finmatch.ioor similar, with DNS control. - Confirmation the SDK entry stays out of scope until there is an appetite for merchant snippet outreach.
8. Related docs
- Versioned immutable asset releases — the immutability a CDN depends on
- Storefront runtime resolution contract — the pointer that makes adoption per-merchant and reversible
- Asset delivery and egress — the verified baseline, cache headers and egress drivers