Summary
Before the payment form can render, a shopper's browser must download three files:
| Asset |
Size (gzip) |
Runs on |
HyperLoader.js |
172.5 KB |
the merchant's page |
app.js |
317.2 KB |
the SDK iframe |
app.css |
9.0 KB |
the SDK iframe |
| Critical path |
498.8 KB |
|
All three must arrive before first paint, so they add directly to the shopper's time-to-checkout. On top of that, every merchant page prefetches icons/orca.svg at 328.7 KB — larger than all the critical-path JavaScript combined.
(Measured with the production webpack config, gzip level 9. Full forensic breakdown: docs/BUNDLE_SIZE_AUDIT.md.)
Why this matters
- The commonly recommended JS budget for mid-tier mobile is 200–450 KB. We are at ~1.1–2.5× that before any API call has been made.
- The loader is embedded by merchants on every checkout page; every byte on the critical path is paid per page view, per merchant, per shopper.
- This is the SDK's most basic quality attribute: a merchant evaluating us measures first paint, and we cannot win that measurement back later.
Where the bytes are
From the audit (docs/BUNDLE_SIZE_AUDIT.md), the critical path breaks down roughly as:
- Sentry — ~25% of the critical path. The SDK initialises Sentry twice: once in
HyperLoader (on the merchant's page) and once in the iframe app. The loader instance traces a third party's site at tracesSampleRate: 1.0 with no usable propagation target, and its Session Replay records a third party's DOM. The app instance's replayIntegration pulls rrweb (~40 KB gzip) onto the paint path even though the form doesn't need it to render.
- The 20-way iframe router.
src/App.res is a single switch on a query parameter; each iframe renders exactly one branch, but all ~20 branches are statically imported, so every iframe downloads every route's subtree.
- Inline data tables. The English locale table, the
Country timezone table (25.8 KB), Phone_number.json (62 KB, 228 dial codes) and BankLogoMapping.json are all compiled into the entry bundles even though most are needed only when a specific field renders.
- A vendored
ua-parser-js (45 KB, 1,689 lines) used to read three fields (os_type, os_version, device_model).
- The loader's public API eager-bundled.
Elements, PaymentSession, PaymentMethodSession, AuthenticationSession and the VGS vault broker are all reachable synchronously from HyperLoader.bs.js, so the whole subtree is on the merchant-page critical path.
- The icon sprite. 128 bank logos needed only for bank-redirect saved methods sit in the prefetched
orca.svg, plus duplicate paypal/interac symbol definitions that also collide on clip-path ids.
- Build hygiene. Source maps (9.7 MB) and a 554 KB bundle report are uploaded to the public S3 bucket;
keep_classnames is set in Terser although ReScript emits no classes.
Proposed direction
Cut bytes off the critical path without removing any behaviour:
- Split Sentry by init path: loader keeps error capture only; app keeps tracing + replay but downloads Replay after first paint (buffer mode requires the integration to exist, so it must be deferred, never removed).
- Lazy-load the 15 non-default
App.res routes with the React.lazy_ + Suspense template already used 22× in the repo; keep preMountLoader eager (it starts the payment-methods API calls).
- Move data tables behind lazy boundaries: locale chunks,
Country/States via import() with the S3 fetch remaining primary, Phone_number.json with the phone fields, BankLogoMapping.json with the bank icon. This needs LoaderController to publish config when the locale chunk lands rather than awaiting the cache-busted S3 fetch.
- Replace the vendored UA parser with a minimal bounded sniff that emits identical values (500-char UA cap, 64-char field cap — the same bounds the library had).
- Loader API behind synchronous facades backed by async chunks (the hard one: the API is called synchronously inside
script.onload, so facades must queue and replay merchant calls in order). Requires fixing output.publicPath first — it is root-relative today, so a chunk requested from the loader 404s on the merchant's origin.
- Split the icon sprite into core (prefetched) + bank logos (on demand), removing duplicate symbols.
- Build config: SDK-origin
publicPath, crossorigin + SRI for cross-origin chunks, hidden-source-map + exclude *.map from the S3 upload, fail the build on an unrecognised sdkEnv, drop Terser keep_classnames.
Notes
- Cypress coverage exists for payment flows; the lazy boundaries touch timing-sensitive paths (facade call replay, Suspense fallbacks on full-screen routes), so the behaviour verification plan in
docs/BUNDLE_SIZE_SUMMARY.md ("Before this ships") should gate release, including CDN CORS headers on the new cross-origin chunk requests — without Access-Control-Allow-Origin, every public API surface rejects.
- An unrecognised-
sdkEnv build failure and the S3 .map exclusion are included because both shipped production-incident-shaped risks today.
Summary
Before the payment form can render, a shopper's browser must download three files:
HyperLoader.jsapp.jsapp.cssAll three must arrive before first paint, so they add directly to the shopper's time-to-checkout. On top of that, every merchant page prefetches
icons/orca.svgat 328.7 KB — larger than all the critical-path JavaScript combined.(Measured with the production webpack config, gzip level 9. Full forensic breakdown:
docs/BUNDLE_SIZE_AUDIT.md.)Why this matters
Where the bytes are
From the audit (
docs/BUNDLE_SIZE_AUDIT.md), the critical path breaks down roughly as:HyperLoader(on the merchant's page) and once in the iframe app. The loader instance traces a third party's site attracesSampleRate: 1.0with no usable propagation target, and its Session Replay records a third party's DOM. The app instance'sreplayIntegrationpulls rrweb (~40 KB gzip) onto the paint path even though the form doesn't need it to render.src/App.resis a singleswitchon a query parameter; each iframe renders exactly one branch, but all ~20 branches are statically imported, so every iframe downloads every route's subtree.Countrytimezone table (25.8 KB),Phone_number.json(62 KB, 228 dial codes) andBankLogoMapping.jsonare all compiled into the entry bundles even though most are needed only when a specific field renders.ua-parser-js(45 KB, 1,689 lines) used to read three fields (os_type,os_version,device_model).Elements,PaymentSession,PaymentMethodSession,AuthenticationSessionand the VGS vault broker are all reachable synchronously fromHyperLoader.bs.js, so the whole subtree is on the merchant-page critical path.orca.svg, plus duplicatepaypal/interacsymbol definitions that also collide onclip-pathids.keep_classnamesis set in Terser although ReScript emits no classes.Proposed direction
Cut bytes off the critical path without removing any behaviour:
App.resroutes with theReact.lazy_+ Suspense template already used 22× in the repo; keeppreMountLoadereager (it starts the payment-methods API calls).Country/Statesviaimport()with the S3 fetch remaining primary,Phone_number.jsonwith the phone fields,BankLogoMapping.jsonwith the bank icon. This needsLoaderControllerto publish config when the locale chunk lands rather than awaiting the cache-busted S3 fetch.script.onload, so facades must queue and replay merchant calls in order). Requires fixingoutput.publicPathfirst — it is root-relative today, so a chunk requested from the loader 404s on the merchant's origin.publicPath,crossorigin+ SRI for cross-origin chunks,hidden-source-map+ exclude*.mapfrom the S3 upload, fail the build on an unrecognisedsdkEnv, drop Terserkeep_classnames.Notes
docs/BUNDLE_SIZE_SUMMARY.md("Before this ships") should gate release, including CDN CORS headers on the new cross-origin chunk requests — withoutAccess-Control-Allow-Origin, every public API surface rejects.sdkEnvbuild failure and the S3.mapexclusion are included because both shipped production-incident-shaped risks today.