Skip to main content

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.

ObjectSizeCache-ControlBuster?When fetched
finmatch-sdk.js (legacy bucket)105.9 KBmax-age=300, swr=86400no (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=3600noevery page (tiered loader)
finmatch-p/css/finmatch.css133.3 KBmax-age=900, swr=3600noevery page
getmerchantconfig response~3 KBmax-max-age=0, s-maxage=30 + ETag/30430 s-bucketed v (deliberate)every page
finmatch-<env>/configs/finmatch-rate-card.jsonp 52,816 B; t 6,311 Bnot the env script/css tier: p public, max-age=3600; t no-cache, max-age=0no on SDK primary path; yes on the finmatch-finance-app.js p-only second candidate ~L707every page (SDK rateCardsUrl)
finmatch-shared/finmatch-rate-card.json (root)253,014 Bmax-age=300yes 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-bustedcontainer render / FA open when products missing or FA path
finmatch-shared/configs/lender-overrides.json59,498 Bmax-age=3600yes 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.json18.0 KBmax-age=3600no 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.json108 Bmax-age=3600yes (SDK ~L180)every page (5-min in-page TTL)
lender logos (e.g. zopa-logo.svg)~24 KB eachmax-age=3600nofeature render
finmatch-shared/analytics/analytics.js4.7 KBmax-age=3600noevery 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 in applyBranchUrlsFromEnvironment). Un-busted.
  • Shared root top-up: gs://finmatch-shared/finmatch-rate-card.json, written by credit-products-api (max-age=300). Used when the env card is missing assigned product IDs / events (SDK applySharedRateCardFallback), 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 fcaStatus is not_authorised, the SDK skips both the env primary fetch and applySharedRateCardFallback, and stores an empty object in finmatch_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. A finmatch-t preview can only exercise the p|t paths (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​

  1. 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 and no-store remain.
  2. Busted lender-overrides fetches — 59.5 KB, uncacheable, every container render on production (p|t).
  3. 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.
  4. 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.
  5. 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:

BucketVolumeDiurnal swing
gs://finmatch-p151.2 GB/day2.12 GB at 04:00 → 10.07 GB at 19:00 (4.7×)
gs://finmatch-shared64.2 GB/day0.55 GB → 4.54 GB (8.2×)
combined215.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 daysMeanAfterChange
22:0977,983 / 79,516 / 79,68079,06034,097−56.9%
23:0977,177 / 80,688 / 80,50679,45745,353−42.9%
07:0975,770 / 76,459 / 75,26675,83136,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/hourBytes attributedShareRemaining requests
5,800 (1.6/sec)4.51 GB88%39,391 at 14.5 KB avg
5,0003.89 GB76%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:

ObjectBytesWritten by
finmatch-shared/finmatch-rate-card.json256,064credit-products-api
finmatch-shared/configs/lender-overrides.json59,498merchant-api
finmatch-p/configs/finmatch-rate-card.json52,816nothing — see below
finmatch-shared/configs/lender-styling.json17,974merchant-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 scenarios
  • env-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)BytesRequestsBytes/request
09:11 – 14:11 (6 h baseline)7.90 GB mean70,631 mean109.2 KB
15:11 (straddles the deploy)6.96 GB75,69489.8 KB
16:11 (fully after)2.14 GB67,72330.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:

ObjectSizeCache-Control todayRefetch frequency today
finmatch-shared/finmatch-rate-card.json253,014 Bmax-age=300every 5 min per active browser
finmatch-shared/configs/lender-overrides.json59,498 Bmax-age=3600hourly
finmatch-shared/configs/lender-styling.json17,974 Bmax-age=3600hourly
finmatch-<env>/configs/finmatch-rate-card.json52,816 B (p)max-age=3600hourly

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-api sets it on the shared rate card.
  • merchant-api sets it on the finmatch-shared copies of lender-styling.json and lender-overrides.json. The decision lives in lib/jsonStore.js, keyed on bucket and path, rather than at each call site — there are ~54 atomicWrite calls in that service and POST /admin/mirror-configs was already found resetting the header during a t → s → p promotion.
  • scripts/apply-immutable-config-headers.sh covers 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​

  1. 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.
  2. 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.
  3. The 253 KB rate card at max-age=300 — the largest remaining item on gs://finmatch-shared, refetched every 5 minutes per active browser. Now content-hashed, so the header change proposed above is all that is left.
  4. Busted rate-card and lender-overrides fetches — resolved (Step 1 above).
  5. 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, env configs/finmatch-merchant-config.json, lender-styling.json, lender-overrides.json and the rate card are single-writer admin/API-owned files (see source-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.yml tiers; rate card via credit-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. When loadRuntime is false, config can come from sandbox env while runtime assets stay on live — version pinning must preserve that split (environment vs sandboxConfigEnv / resolvePreviewBranch).
  • sync-to-gcs.yml guardrails: ghost-runtime guard on finmatch-shared, workflow-drift guard on env branches, protected- file exclusions. A releases/ 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-p and gs://finmatch-shared return access-control-allow-origin: https://<merchant-domain> for allowed origins and no header at all for others (verified 24 Aug 2026). The list is applied by merchant-api to a hardcoded CORS_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 a merchant-api change 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.