Skip to content

sdk bundle size: shoppers download ~499 KB of gzip before a payment form can paint #1776

Description

@ArushKapoorJuspay

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:

  1. 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.
  2. 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.
  3. 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.
  4. A vendored ua-parser-js (45 KB, 1,689 lines) used to read three fields (os_type, os_version, device_model).
  5. 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.
  6. 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.
  7. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions