Storefront asset delivery and the egress surface
For the operational levers — moving merchants between release channels, pulling one back, applying cache headers, and measuring the result — see the Egress Operations Runbook.
Verified against live buckets, live HTTP headers and the shipped code on
23 Aug 2026; re-verified 24 Aug 2026. This documents how bytes actually
reach shopper browsers — the consumption side that
source-of-truth.md (which covers the publishing side, git → GCS)
does not describe. It exists as the factual baseline for the
versioned-asset / egress-reduction programme.
1. How a page load resolves assets today
Every merchant page load follows resolve-then-fetch (not redirect):
inline snippet (fixed URL, ?merchantID=…&v=1.0)
└─ finmatch-sdk.js (gs://finmatch-finance-marketing-assets/s/scripts/)
└─ GET getmerchantconfig-staging…run.app?merchantID=…&v=<30s bucket>
reads merchant-router.json + env config server-side,
returns { merchantConfig…, environment: p|s|t [, sandbox…] }
└─ SDK derives most asset URLs client-side from `environment`:
scripts → gs://finmatch-<env>/scripts/*.js
css → gs://finmatch-<env>/css/finmatch.css
rate card → gs://finmatch-<env>/configs/finmatch-rate-card.json
(primary) + shared-root top-up when products missing
styling → gs://finmatch-shared/configs/lender-styling.json
for p|t; env-local for s
overrides → shared for p|t (busted); relative env path for s
plus several hardcoded shared URLs (analytics-capture, logos,
analytics.js, SHARED_RATE_CARD_URL) that do not go through
EnvironmentConfig
What merchant-router.json does and does not do. The router decides
which environment's merchant config to load (and sandbox flags). It
does not route asset traffic. Asset bytes go straight to
storage.googleapis.com/... using URLs the client constructs. The
client never fetches merchant-router.json itself (removed Dec 2025);
getMerchantConfig reads it server-side from a secrets-backed bucket
(MERCHANT_ROUTER_BUCKET). Public gs://finmatch-shared/merchant-router.json
is 404 by design.
Extending the resolver response with a version / channel / flags block
is an additive change to this existing indirection, not a new mechanism.
There is no asset CDN: every URL above is storage.googleapis.com
direct, so every cache miss is billable GCS internet egress. The only
Cloudflare presence is the finance-assistant proxy Worker
(cloudflare/finance-assistant-proxy), which does not serve assets.
2. Legacy header-snippet shims
gs://finmatch-finance-marketing-assets/ lists nine entries (folder
placeholders included) and four content objects: the canonical SDK
at s/scripts/finmatch-sdk.js plus three copies of
{p,s,t}/scripts/finmatch-header-loader.js (2,210 B each,
max-age=60 on the marketing-assets copies).
The shims exist for merchants whose old inline snippets still load
finmatch-header-loader.js from the pre-router bucket layout. The
/p/, /s/, /t/ in those URLs is not the runtime environment —
it is only which copy of the shim the old snippet happens to request.
The shim reads localStorage.finmatchConfig, recovers the merchantID,
and injects the canonical SDK, which then routes normally via
getMerchantConfig → merchant-router.json. History: July 2026 prune,
cleanup-runbook-2026-07-legacy-bucket.md.
Do not confuse with identical-named files at
gs://finmatch-{p,s,t}/scripts/finmatch-header-loader.js (env-bucket
sync copies, max-age=900). Live snippets that still use the shim
target the marketing-assets paths, not the env copies.
Caution for the versioning work: the shim appends
v=${stored.version || Date.now()} to the SDK URL. Any legacy snippet
that never stored a version busts the SDK cache per shim execution.
Audit which merchants still run shim snippets before assuming the SDK
cache hit rate.
3. Per-page-view fetch inventory (production merchant)
Live headers verified 23 Aug 2026; CSS size re-checked 24 Aug 2026.
"Buster" means the request carries a per-millisecond query string
and cache: 'no-store', so it can never be served from any cache —
browser, proxy or future CDN.
| Object | Size | Cache-Control | Buster? | When fetched |
|---|---|---|---|---|
finmatch-sdk.js (legacy bucket) | 105.9 KB | max-age=300, swr=86400 | no (static v=1.0; shim caveat §2) | every page |
finmatch-p/scripts/*.js (~8–12 files) | ~450–550 KB total (finance-container.js alone 174.5 KB) | max-age=900, swr=3600 | no | every page (tiered loader) |
finmatch-p/css/finmatch.css | 133.3 KB | max-age=900, swr=3600 | no | every page |
getmerchantconfig response | ~3 KB | max-max-age=0, s-maxage=30 + ETag/304 | 30 s-bucketed v (deliberate) | every page |
finmatch-<env>/configs/finmatch-rate-card.json | p 52,816 B; t 6,311 B | not the env script/css tier: p public, max-age=3600; t no-cache, max-age=0 | no on SDK primary path; yes on the finmatch-finance-app.js p-only second candidate ~L707 | every page (SDK rateCardsUrl) |
finmatch-shared/finmatch-rate-card.json (root) | 253,014 B | max-age=300 | yes in finance-container.js (Propensio top-up ~L172, Snap top-up ~L228) and finmatch-finance-app.js ~L706; SDK shared top-up path is un-busted | container render / FA open when products missing or FA path |
finmatch-shared/configs/lender-overrides.json | 59,498 B | max-age=3600 | yes at two sites: finance-container.js ~L282 and quick-start.js loadLenderOverridesForBadge ~L214 (shared-overrides path = p|t production) | container render and quick-start badge mount on p|t |
finmatch-shared/configs/lender-styling.json | 18.0 KB | max-age=3600 | no on SDK page path; yes on FA refreshLenderStylingData() for finmatch-p (?ts= + no-store, ~L900–L912) | every page (SDK); FA / universal-callback setup |
finmatch-shared/configs/analytics-capture-config.json | 108 B | max-age=3600 | yes (SDK ~L180) | every page (5-min in-page TTL) |
lender logos (e.g. zopa-logo.svg) | ~24 KB each | max-age=3600 | no | feature render |
finmatch-shared/analytics/analytics.js | 4.7 KB | max-age=3600 | no | every page |
Dual rate-card model (important for versioning)
Writer vs readers: credit-products-api is the only catalogue writer
(gs://finmatch-shared/finmatch-rate-card.json). Env
configs/finmatch-rate-card.json files are legacy snapshots with no
live writer — not a second SSOT. Target: one event-driven catalogue,
merchant-scoped resolver delivery; retire env objects and dual-fetch.
See Rate cards overview.
- SDK primary:
finmatchConfig.rateCardsUrl=gs://finmatch-<env>/configs/finmatch-rate-card.json(set inapplyBranchUrlsFromEnvironment). Un-busted. - Shared root top-up:
gs://finmatch-shared/finmatch-rate-card.json, written bycredit-products-api(max-age=300). Used when the env card is missing assigned product IDs / events (SDKapplySharedRateCardFallback), and aggressively re-fetched with?ts=from the container / FA paths above. - Env mirrors differ in size from shared and from each other; do not treat them as identical.
- Not authorised skip (live): when runtime
fcaStatusisnot_authorised, the SDK skips both the env primary fetch andapplySharedRateCardFallback, and stores an empty object infinmatch_rate_cards. See Rate cards overview.
Which env a module thinks it is running in
Several busted paths are gated not on the resolver's environment but on the
module sniffing its own URL:
const scriptUrl = new URL(import.meta.url);
const isFinmatchP =
scriptUrl.hostname === 'storage.googleapis.com' &&
scriptUrl.pathname.includes('/finmatch-p/');
Seven such sites exist across scripts/finance-container.js (~L160, ~L218,
~L280, ~L289) and scripts/finmatch-finance-app.js (~L678, ~L908, ~L915) on
finmatch-p. Two consequences matter for this programme:
- Sandbox preview cannot validate the p-gated paths. With "Preview link
loads: Test build", assets come from
gs://finmatch-t/, so the Propensio and Snap shared rate-card top-ups return early without fetching. Afinmatch-tpreview can only exercise thep|tpaths (shared lender-overrides, quick-start badge). - Versioned asset paths change the answer. Any base URL without a
/finmatch-p/segment silently flips these gates. Two of the sites also resolve config relatively (new URL('../configs/lender-overrides.json', import.meta.url)), which points at a non-existent object under a versioned path. See Versioned immutable asset releases §8.
Hardcoded shared URLs
Not everything goes through EnvironmentConfig. Constants such as
LENDER_STYLING_URL, SHARED_RATE_CARD_URL,
ANALYTICS_CAPTURE_CONFIG_URL, and logo bases are hardcoded to
finmatch-shared. Extending only the resolver is not enough for a
complete versioned-asset design — inventory every fetch site.
finmatch-s still differs
usesSharedLenderStyling is true for p|t only. Staging (s) still
uses env-local styling; overrides on s use a relative env path rather
than the busted shared URL. Do not assume p/t shared behaviour for s.
Manual bust escape hatch
finmatch.bustCache uses ?cb= + no-store. Separate from the
steady-state busters in the table; leave it alone unless redesigning
admin "force refresh" UX.
4. Ranked egress drivers
- Busted rate-card fetches — 253 KB shared root, uncacheable, per
qualifying page view; a merchant assigned both Propensio and Snap
products can trigger it twice on one page (~506 KB). This is the
single largest recurring transfer and no header tuning can help
while the
?ts=busters andno-storeremain. - Busted lender-overrides fetches — 59.5 KB, uncacheable, every container render on production (p|t).
- Script + CSS re-downloads — ~700 KB for a cold visitor; the 900 s max-age means even returning visitors re-validate/refetch roughly every 15 minutes per object, and there is no shared cache in front of GCS to absorb any of it.
- SDK — 105.9 KB at
max-age=300: refetched every 5 minutes per active browser (SWR softens latency, not egress), plus the shim caveat in §2. - Everything else (logos, styling, analytics, config responses) is comparatively small but each busted request is also a billable Class B operation.
4a. Measured baseline (25 Aug 2026)
Cloud Monitoring, storage.googleapis.com/network/sent_bytes_count, hourly.
Sunday 23 Aug, a clean full day:
| Bucket | Volume | Diurnal swing |
|---|---|---|
gs://finmatch-p | 151.2 GB/day | 2.12 GB at 04:00 → 10.07 GB at 19:00 (4.7×) |
gs://finmatch-shared | 64.2 GB/day | 0.55 GB → 4.54 GB (8.2×) |
| combined | 215.4 GB/day |
Step 1 (buster removal) — verified
Measured as bytes per request, which normalises for traffic volume so a
quiet day cannot flatter the result. gs://finmatch-shared, comparing each
hour against the same hour on the three preceding days:
| Hour (UTC) | Baseline days | Mean | After | Change |
|---|---|---|---|---|
| 22:09 | 77,983 / 79,516 / 79,680 | 79,060 | 34,097 | −56.9% |
| 23:09 | 77,177 / 80,688 / 80,506 | 79,457 | 45,353 | −42.9% |
| 07:09 | 75,770 / 76,459 / 75,266 | 75,831 | 36,844 | −51.4% |
gs://finmatch-p over the same hours: −0.1%, +3.3% — flat, as predicted, since
scripts and CSS did not change. Roughly 32 GB/day saved.
Hours 00:00–06:00 on 25 Aug are excluded: that window is contaminated by the programme's own activity (CI runs, doc deploys, the first release publish, browser test harnesses).
The largest driver is not client-side
getMerchantConfig downloaded both merchant-router.json and the whole
env configs/finmatch-merchant-config.json on every request, uncached, to
extract one ~3 KB merchant row. The env config is 777,184 bytes for 110
merchants, so over 99% of those bytes were discarded.
Decomposing gs://finmatch-p at 07:09 on 25 Aug (5.09 GB over 45,191
requests):
| Assumed config downloads/hour | Bytes attributed | Share | Remaining requests |
|---|---|---|---|
| 5,800 (1.6/sec) | 4.51 GB | 88% | 39,391 at 14.5 KB avg |
| 5,000 | 3.89 GB | 76% | 40,191 at 29.4 KB avg |
Two independent signals support it. First, 1.6 requests/second across 110
merchants is ordinary traffic, and it leaves the other requests averaging
14.5 KB, which is what asset fetches and 304s look like. Second, and more
telling, bytes per request rises 1.52× overnight on finmatch-p (163.9 KB
at 02:00–05:00 versus 108.1 KB at 13:00–16:00). A fixed large download once
per page view must produce exactly that, because incidental asset requests
fall away at night while the fixed cost does not. Shopper asset traffic alone
cannot produce it.
merchant-api has the same uncached full-file read (readMerchantConfigForEnv,
13 call sites, plus 8 via getMerchantRowFromEnv). That path is driven by
admin activity rather than page views, so it is bursty rather than constant,
but it is the same waste. A read cache there needs write-invalidation, so it
is tracked separately.
Not a driver: the admin dashboard. It holds direct production CONFIG_URLS
but loads each env config once per page session into environmentConfigs, and
the only setInterval calls in the admin bundle are in analytics.js and
embed-isolation.js. There is no config polling.
Immutable headers on the hashed config objects
Four objects are fetched only through content-hashed URLs, so they carry
public, max-age=31536000, immutable:
| Object | Bytes | Written by |
|---|---|---|
finmatch-shared/finmatch-rate-card.json | 256,064 | credit-products-api |
finmatch-shared/configs/lender-overrides.json | 59,498 | merchant-api |
finmatch-p/configs/finmatch-rate-card.json | 52,816 | nothing — see below |
finmatch-shared/configs/lender-styling.json | 17,974 | merchant-api |
The invariant this depends on. Cache-Control is a property of the object,
not of the URL, so the header applies to the bare URL too. A single bare fetch
would pin those bytes in that browser for a year — stale APRs on a finance
quote. The header is therefore only safe while every request carries a
distinguishing query: the resolver's ?h=, a stable ?cb=, or styling's
?ts=. Both harnesses assert it:
sdk-runtime-trace.mjs— no immutable candidate fetched at a bare URL, across all 8 scenariosenv-runtime-signals.mjs— the same, plus no per-request?ts=creeping back onto overrides or rate cards
If a new consumer fetches one of these four bare, the header must come back down before that consumer ships.
Deliberately excluded. finmatch-ecom-config.json and the env-scoped
lender-styling.json / lender-overrides.json copies in finmatch-t and
finmatch-s are still fetched bare, so they keep the bucket default. The policy
is therefore keyed on bucket and path, not on file name alone: the same code
writes both the shared copy and the env copies.
Where the policy lives, and why. In merchant-api it is applied inside
lib/jsonStore.js rather than at the call sites. There are ~54 atomicWrite
calls in that service, several with a bucket and path resolved at runtime — most
notably POST /admin/mirror-configs, which full-replaces these same objects
during a t -> s -> p promotion. An opt-in per call site meant that path reset
the header on every promotion, silently undoing both the admin PUTs and the
one-off script. Deciding in the choke point means a new writer inherits the
right header without knowing this policy exists. An explicit cacheControl
option still wins, so the header can be lowered quickly if needed.
finmatch-p/configs/finmatch-rate-card.json has no writer. Last modified
1 April 2026, and sync-to-gcs explicitly excludes finmatch-rate-card.json
from the configs/ rsync. The live object (52,816 B) also differs from the
repo copy (241,724 B). It is either a deliberate frozen override or rot; either
way it is inert, which makes it safe to mark immutable. Worth resolving as part
of the rate-card SSOT work rather than here.
Resolver cache — verified
Deployed 25 Aug 2026 15:53 UTC (Cloud Run revision
getmerchantconfig-staging-00022-bjc). gs://finmatch-p, hourly:
| Hour (UTC) | Bytes | Requests | Bytes/request |
|---|---|---|---|
| 09:11 – 14:11 (6 h baseline) | 7.90 GB mean | 70,631 mean | 109.2 KB |
| 15:11 (straddles the deploy) | 6.96 GB | 75,694 | 89.8 KB |
| 16:11 (fully after) | 2.14 GB | 67,723 | 30.8 KB |
- bytes per request −71.8%
- absolute volume −72.9%
- request count −4.1%, so traffic held — this is not a quiet-hour artefact
Live cache hit rate on the service: 95.6% (86 hits / 4 misses per
instance, reported by GET ?_version=1), despite containerConcurrency: 1
meaning the cache is per-instance.
Working back from the measured reduction and hit rate, the config download was
76% of finmatch-p bytes — the middle scenario of the decomposition
above, not the 88% upper one.
Projected: 151.2 → 42.7 GB/day, about 108 GB/day saved. With Step 1's
~32 GB/day on finmatch-shared, roughly 140 of 215.4 GB/day, or 65% of
platform egress, from two changes that alter no merchant-visible behaviour.
Content-hashed config URLs, and the header change they enable
The resolver issues ?h=<md5-prefix> URLs for every admin/API-written config
object a given environment actually reads:
| Object | Size | Cache-Control today | Refetch frequency today |
|---|---|---|---|
finmatch-shared/finmatch-rate-card.json | 253,014 B | max-age=300 | every 5 min per active browser |
finmatch-shared/configs/lender-overrides.json | 59,498 B | max-age=3600 | hourly |
finmatch-shared/configs/lender-styling.json | 17,974 B | max-age=3600 | hourly |
finmatch-<env>/configs/finmatch-rate-card.json | 52,816 B (p) | max-age=3600 | hourly |
The hash makes a long cache safe, because the URL changes the moment the writer changes the object. It does not by itself make the cache long — that is a header change, and these are single-writer files, so it needs sign-off.
Done (25 Aug 2026): all four are served with
public, max-age=31536000, immutable. The reasoning that follows is why it is
safe, and is kept because the safety argument is the load-bearing part.
Why it is safe with hashed URLs:
- The resolver always issues the current hash, and it re-reads object metadata every 30 s, so a client asks for a stale URL for at most ~30–60 s after a write — the same window the resolver response itself already has.
- GCS ignores the query string for object identity, so a client that does request an old hash gets the current bytes. It is self-correcting, not wrong.
- Cache entries under superseded hashes are simply never requested again.
Why the rate card is the prize: at max-age=300 it is refetched every five
minutes per active browser, and it is 4× the size of the next largest object.
Consumers are the SDK top-up (applySharedRateCardFallback), the
finance-container.js Propensio and Snap top-ups, and
finmatch-finance-app.js — all on p.
How it was applied. Both halves had to land together: a setmeta for the
copies that already existed, and a change in each writer so the header survives
the next write.
credit-products-apisets it on the shared rate card.merchant-apisets it on thefinmatch-sharedcopies oflender-styling.jsonandlender-overrides.json. The decision lives inlib/jsonStore.js, keyed on bucket and path, rather than at each call site — there are ~54atomicWritecalls in that service andPOST /admin/mirror-configswas already found resetting the header during at → s → ppromotion.scripts/apply-immutable-config-headers.shcovers the existing copies and is idempotent.
One object has no writer. finmatch-p/configs/finmatch-rate-card.json was
given the header by the script, but nothing writes it — last modified 1 Apr
2026, and excluded from the sync-to-gcs config rsync. It is inert, which is
what makes marking it safe, but it is not protected by a writer the way the
other three are. See ssot-consolidation-plan.md.
The env-scoped copies of styling and overrides in finmatch-t and finmatch-s
deliberately keep the bucket default: storefront modules still fetch those bare,
and an immutable header on a bare URL would pin stale content for a year.
Revised ranking
- Resolver config + router downloads — ~759 KB per page view, every page, server-side and invisible from the client. Resolved by a 30 s in-memory cache inside the freshness envelope the response already advertises: −71.8% bytes/request, measured above.
- Script + CSS re-downloads — now the largest client cost on
gs://finmatch-p, ~1.90 GB of the remaining 2.14 GB/hour at peak. Addressed by versioned immutable releases. - The 253 KB rate card at
max-age=300— the largest remaining item ongs://finmatch-shared, refetched every 5 minutes per active browser. Now content-hashed, so the header change proposed above is all that is left. - Busted rate-card and lender-overrides fetches — resolved (Step 1 above).
- SDK at
max-age=300, plus the legacy shim caveat in §2.
The original §4 ranking below predates these measurements and is retained for the reasoning about why the busters existed.
4b. Why the busters existed
The busters exist because file content changes under a stable URL,
and stale finance figures are a correctness risk the authors chose to
pay for in egress. Versioned immutable URLs remove that trade-off:
content at a URL never changes, so max-age=31536000, immutable
becomes safe, and freshness moves to the one small mutable resolver
response (getMerchantConfig), which already has the right caching
model (30 s buckets + ETag/304).
5. Invariants any versioning design must respect
merchant-router.json, envconfigs/finmatch-merchant-config.json,lender-styling.json,lender-overrides.jsonand the rate card are single-writer admin/API-owned files (seesource-of-truth.md). A version manifest must not be writable by git sync if an API also writes it — pick one writer.- Cache-Control today is set by different writers: SDK/scripts/css via
sync-to-gcs.ymltiers; rate card viacredit-products-api; overrides/styling via merchant-api writes. A versioned publish path must not fight these writers. - The sandbox preview (
sandbox.loadRuntime) and the admin "Preview link loads: Test build" toggle depend on the SDK deriving asset URLs from the resolver response per session. WhenloadRuntimeis false, config can come from sandbox env while runtime assets stay on live — version pinning must preserve that split (environmentvssandboxConfigEnv/resolvePreviewBranch). sync-to-gcs.ymlguardrails: ghost-runtime guard onfinmatch-shared, workflow-drift guard on env branches, protected- file exclusions. Areleases/publishing step belongs beside these, not around them. Concrete touchpoints live in that workflow's marketing-assets + env script/css steps and the protected-file regex.- The canonical SDK entry URL
(
…/s/scripts/finmatch-sdk.js?merchantID=…) is burned into merchant pages and cannot be versioned; it must stay a small, short-cache bootstrap that resolves a version rather than being one. - CORS is a per-origin allow-list, not
*.gs://finmatch-pandgs://finmatch-sharedreturnaccess-control-allow-origin: https://<merchant-domain>for allowed origins and no header at all for others (verified 24 Aug 2026). The list is applied bymerchant-apito a hardcodedCORS_TARGET_BUCKETS = ['finmatch-finance-marketing-assets', 'finmatch-shared', 'finmatch-p', 'finmatch-s', 'finmatch-t']. ES module imports are always CORS-mode fetches, so serving assets from a new bucket requires amerchant-apichange and a CORS reconcile before any merchant can load them — not just a workflow change.
Design proposal built on this baseline: Versioned immutable asset releases.