Skip to content

(Status Update) Coin Implementation Defer Loading #16819

@gre-ledger

Description

@gre-ledger

Initiative — History, Discoveries & What's Next · April 2026

Epic: LIVE-29176 · ADR: Coin Module Loading Strategy


1 — The Problem

Every Ledger Live app imports all ~29 coin families at startup through generated barrel files in libs/ledger-live-common/src/generated/.

~20 MB of compressed JS parsed and executed before the app is interactive, for every user, every boot — regardless of which coins they hold.

// generated/bridge.ts — the original problem
export * from "@ledgerhq/coin-bitcoin/bridge";
export * from "@ledgerhq/coin-evm/bridge";
export * from "@ledgerhq/coin-tron/bridge";   // registers protobuf — breaks Bun runtimes
export * from "@ledgerhq/coin-solana/bridge";
// … 25 more, all evaluated eagerly, always

Twelve files like this. One per domain (bridge, transaction, serialization, …).

Baseline:

Metric Value
CI desktop unit tests ~18 min
LLD boot time (M1, optimised build) ~1.4 s (T-load)
LLM bundle ~56.4 MB

2 — Exploration 1: Go straight to async import() (PR #16002, Apr 2026)

The right fix is obvious: replace every barrel export with a dynamic import().

- export * from "@ledgerhq/coin-bitcoin/bridge";

+ loadBridge: () => import("@ledgerhq/coin-bitcoin/bridge.js"),

We explored this fully. First measurements were very promising:

DEVELOP
  T-load: 1.340s  /  TOTAL BOOT: 1959ms

explo/live-28411 (async import())
  T-load: 1.048s  /  TOTAL BOOT: 1673ms
  → T-load 30% faster

On desktop unit tests (local Mac): 8 min → 3 min.
Bundle size: −5 MB LLD, −6 MB LLM (coin code absent from main chunk).

The problem: getAccountBridge and getCurrencyBridge are synchronous and used everywhere.
Making them async propagates through 267 files across LLD, LLM, live-common, live-wallet:

// React components → need new async-aware hook (React 19 use() + Suspense)
const bridge = useAccountBridge<FamilyTx>(account, parentAccount);

// Event handlers / effects → manual .then()
getAccountBridge(account).then(bridge => bridge.sync(...));

// RxJS pipelines → from() wrapper
from(getAccountBridge(account)).pipe(switchMap(b => b.sync(...)));

// Redux reducers → can't be async, must use sync framework helpers
import { clearAccount, isAccountEmpty } from "@ledgerhq/ledger-wallet-framework/account";

// Operation checks → useState + useEffect everywhere
const [isEditable, setIsEditable] = useState(false);
useEffect(() => { isEditableOperation({account, op}).then(setIsEditable); }, [...]);

Too much blast radius for a single PR. We needed a staged approach.


3 — Phase 1: The require() Registry (PR #16031, merged April 9 2026)

Plan: replace the 12 barrel files with a require()-based registry.
require() inside a function is lazy in Node.js — the module loads only on first call.
Key benefit: public API stays 100% synchronous — zero call-site changes.

// BEFORE — generated/bridge.ts (eager, always)
- export * from "@ledgerhq/coin-bitcoin/bridge";
- export * from "@ledgerhq/coin-evm/bridge";
- // … 27 more

// AFTER — coin-modules/loaders.ts
+ {
+   family: "bitcoin",
+   loadSetup:       () => require("../families/bitcoin/setup"),
+   loadTransaction: () => require("@ledgerhq/coin-bitcoin/transaction").default,
+   loadBridge:      () => require("../families/bitcoin/bridge").default,
+ },
+ // … 28 more families

Registry structure:

coin-modules/
  registry.ts       ← Map<family, CoinModuleLoader>, sync load*ForFamily()
  loaders.ts        ← 29 families, each a require() per slot
  load-all-coins.ts ← registerAllCoins() at app startup
  types.ts          ← CoinModuleLoader interface (the contract)

Wins

Metric Before After
CI desktop unit tests ~18 min ~12 min
LLD boot time (M1) ~1.4 s ~1.0 s
Call-site changes 0

Mobile bundle was initially exciting: CI reported 56.4 MB → 50.2 MB (−6 MB).
That turned out to be a false positive — mobile wasn't wiring up coin modules the same way, so the old path simply wasn't importing them properly. Not a real gain.


4 — The Bad: require() is CJS in an ESM world

require() inside functions is opaque to bundlers. It breaks scope hoisting (module concatenation, tree-shaking across module boundaries).

The +2.7 MB desktop bundle regression — root cause analysis:

rspack can hoist and tree-shake a static import:
  import aleoTx from "@ledgerhq/coin-aleo/transaction";
  → inlined, dead-code eliminated, wrapper stripped

rspack treats require() inside a lambda as a black box:
  loadTransaction: () => require("@ledgerhq/coin-aleo/transaction").default
  → emits CJS wrapper, no hoisting, no elimination

Biggest offenders pulled in as CJS instead of ESM:

Package Added Root cause
@hashgraph/sdk ~470 KB .cjs files instead of ESM
ethers/lib.commonjs/* ~260 KB CJS path selected over lib.esm/
viem/_cjs/* ~150 KB CJS path instead of _esm/
@aptos-labs/ts-sdk ~149 KB New ESM chunk
Coin setup.js files ~400 KB stacks, sui, ton, filecoin, icp, …

We explored a custom rspack loader to transform the compiled loaders.js back to static ESM imports at bundle time — it worked, but added build complexity we didn't want to maintain.

The deeper problem — ESM × CJS two states:
With require() in live-common, live-env (and potentially other shared singletons) could end up instantiated twice — once through the CJS module cache, once through the ESM module graph. Two states of the same module in memory. No apparent bug yet, but a ticking time bomb for subtle, hard-to-diagnose issues.


5 — Mobile: the async chunk surprise (PR #16408, April 16)

The first real dynamic import() we introduced in live-common was getAlpacaApi (PR #16333).

What happened: rspack (Re-Pack) immediately emitted an async chunk for it — a separate .js file expected to be loaded from a remote chunk server at runtime.

Mobile has no ScriptManager configured and no chunk server. The app crashed at runtime trying to fetch a chunk that didn't exist:

Loading chunk libs_ledger-live-common_lib_bridge_generic-alpaca... ← runtime error

Fix (PR #16408): set output.asyncChunks: false in the mobile rspack config.

// apps/ledger-live-mobile/metro.config.js (rspack)
+ output: {
+   asyncChunks: false,   // mobile has no ScriptManager; disable async chunk emission
+ },

This is mobile-specific policy — desktop can and should use async chunks from those same imports. The fix belongs at the app layer, not in live-common.


6 — Mobile Infrastructure: Kevin's Re-Pack lazyImports (PR #16501, April 20)

While the runtime chunk issue was blocked, Kevin (KVNLS) activated lazyImports in the Re-Pack Babel/SWC loader (LIVE-29415):

// re-pack babel loader config
+ lazyImports: true

This tells the Re-Pack bundler to treat import() calls as truly deferred at the Hermes bytecode level — modules are not parsed or evaluated until first access. It's the mobile equivalent of what rspack's code splitting does on desktop.

Performance impact at this stage was unclear (hard to isolate from other changes), but the infrastructure is now ready to see real gains once the coin module loaders flip to async import().


7 — Securing the Boundary (PRs #16470 + #16508, April 21)

While making the call chain async, we needed to ensure no one accidentally re-introduced coin-specific imports into generic code.

Non-regression test suite (PR #16508)

Added no-coin-eager-imports.test.ts — a bundle-level check using esbuild's metafile:

// non-regression-entry.ts — every shared surface that touches the registry
export { isAccountEmpty, clearAccount, getVotesCount } from "../account/helpers";
export { isEditableOperation, isStuckOperation } from "../operation";
export { getCurrencyBridge, getAccountBridge } from "../bridge/impl";
export { fromTransactionRaw } from "../transaction/index";
export { getDeviceTransactionConfig } from "../transaction/deviceTransactionConfig";
export { accountToWalletAPIAccount } from "../wallet-api/converters";
// …

The test builds this entry point and asserts no @ledgerhq/coin-* package appears in the bundle output, against an explicit allowlist. The allowlist must shrink to [].

Remove all direct coin-* imports from shared code (PR #16470)

Swept every @ledgerhq/coin-* import outside src/coin-modules/ and src/families/ — routing all of them through the registry:

// account/helpers.ts — before
- import { isAccountEmpty } from "@ledgerhq/coin-cosmos/helpers";
- import { isAccountEmpty } from "@ledgerhq/coin-tron/index";

// account/helpers.ts — after
+ const fn = loadIsAccountEmptyForFamily(account.currency.family);
+ if (fn) return fn(account);

Side effect: wallet-cli no longer needs the Tron protobuf polyfill — coin-tron is simply not in its bundle anymore.

A Copilot rule was added to .github/copilot-instructions.md to enforce the boundary on future contributions:

Runtime imports of coin-specific code — from @ledgerhq/coin-* or from ../families/* — are only allowed inside src/coin-modules/ and src/families/.


8 — The Async Call Chain (merged, April 20–24)

With the boundary secured, we progressively added await to every call site up the chain — before the functions even became async. await on a synchronous value is a TypeScript-valid no-op, so this prepared all call sites for the flip with zero runtime risk.

Series of PRs (all merged):

PR What Date
#16350 fromTransactionRaw, account serialization, sign chain Apr 20
#16351 platform/wallet-api converters, prepareMessageToSign Apr 20
#16352 transaction serialization helpers Apr 20
#16333 getAlpacaApi → first real async import() in live-common Apr 16
#16456 getAccountBridge, getCurrencyBridge call sites add await Apr 23
#16748 wallet-api and platform logic.ts properly await bridge + converters Apr 24
#16751 getDeviceTransactionConfig properly awaits its loader Apr 23
#16753 hw/actions inferCommandParams, getAddress, signMessage made async Apr 24

Pattern repeated across all of these:

// Before — synchronous, call site unaware
- const bridge = getAccountBridge(account, parentAccount);
- const tx = bridge.createTransaction(account);

// After — await added, no behaviour change today
+ const bridge = await getAccountBridge(account, parentAccount);
+ const tx = bridge.createTransaction(account);

9 — The Hard Part: useBridgeTransaction + Suspense

useBridgeTransaction is used in ~107 files across LLD and LLM (Send, Swap, Earn, staking flows). It calls getAccountBridge synchronously in a reducer initialiser. This is the last major blocker for making the bridge loader truly async.

Attempt 1 — React use() + Suspense (PR #16602, closed)

- const { transaction } = useBridgeTransaction(() => {
-   const bridge = getAccountBridge(account, parentAccount);
+ const { transaction } = useBridgeTransaction(async () => {
+   const bridge = await getAccountBridge(account, parentAccount);
    return { account, parentAccount, transaction: bridge.createTransaction(account) };
  });

Problem: using React.use(Promise) in the hook caused cascading Suspense on every screen mount — the UI would blank-flash every time you navigated to a Send screen. Unacceptable UX.

Attempt 2 — Pre-annotated fulfilled Promise (PR #16726, open)

The key insight: we don't want to suspend on every mount — only on the very first access of a coin family. Solution: wrap the sync result in a pre-annotated fulfilled Promise that React's use() can read synchronously without triggering a Suspense boundary.

// useAccountBridge — returns synchronously today, suspends naturally later
- const bridge = getAccountBridge(account, parentAccount);
+ const bridge = useAccountBridge(account, parentAccount);
//               ↑ wraps result in pre-resolved Promise; use() reads it sync
//                 when getAccountBridge becomes truly async, annotation is removed
//                 and Suspense fires only on first-ever family load, not every mount

// useBridgeTransaction — initialises state synchronously via useReducer lazy init
- const { transaction } = useBridgeTransaction(() => ({ account, parentAccount }));
+ const bridge = useAccountBridge(account, parentAccount);
+ const { transaction } = useBridgeTransaction(bridge, () => ({ account, parentAccount }));
//                                             ↑ bridge is now an explicit argument

When getAccountBridge becomes truly async, removing the pre-annotation is the only change needed — all ~107 call sites are already migrated.


10 — The Rollback Decision (PR #16795, open)

After shipping the async call chain work, the ESM × CJS two-states risk became the deciding factor.

The concern:
With require() in loaders.ts, live-env and other shared singletons can end up with two instances in memory — one loaded through the CJS module cache, one through the ESM path. No visible bug today, but any code that does singleton === otherSingleton or holds module-level state would silently misbehave.

"i'm afraid it means we move back from Desktop Unit Tests 12min → 18min like 2 weeks ago but don't worry @lewis.phillips-ext, we're not too far to come back with a 18min → 6min proposal"

Decision: revert loaders.ts to top-level static import.
Keep the CoinModuleLoader registry and interface 100% intact.

// loaders.ts
+ import aleoTransaction from "@ledgerhq/coin-aleo/transaction";
+ import bitcoinSetup from "../families/bitcoin/setup";
+ // … all 29 families, static top-level imports

  export const coinModuleLoaders: CoinModuleLoader[] = [
    {
      family: "aleo",
-     loadTransaction: () => require("@ledgerhq/coin-aleo/transaction").default,
+     loadTransaction: () => aleoTransaction,
    },
    {
      family: "bitcoin",
-     loadSetup: () => require("../families/bitcoin/setup"),
+     loadSetup: () => bitcoinSetup,
    },
  // …
  ];

Mock bridges stay on require() (test-only, static import would pull mockHelpers → bridge/impl → generic-alpaca before jest.mock() intercepts — breaking test isolation).

ESM × CJS two-states risk eliminated
Bundle size back to baseline
CI test time ~18 min temporarily
Registry architecture fully preserved
Foundation for async import() clean

11 — Architecture Decision (ADR)

Three decisions locked in:

1. Single unified loading pattern — all coin-specific code routes through CoinModuleLoader. No @ledgerhq/coin-* import in generic/shared code, ever.

2. loaders.ts in live-common short-term; app-level long-termwallet-cli already registers only 3 families, injecting its own loader config. This is the template for per-app flexibility (replaces setSupportedCurrencies()).

3. CI non-regression enforcement — the bundle check on non-regression-entry.ts must stay green. The allowlist must shrink to [].


12 — Where We Are Now

✅ Barrel files replaced by CoinModuleLoader registry
✅ All direct coin-* imports removed from shared live-common code
✅ Non-regression test suite in CI (allowlist shrinking)
✅ Async call chain prepared (account/tx serialization, bridge, wallet-api, platform, device-tx, hw/actions)
✅ Mobile RePack lazyImports activated (Kevin, LIVE-29415)
✅ Mobile asyncChunks:false guard (no runtime crash on first import())
✅ useBridgeTransaction Suspense-ready pattern found (PR #16726)
⏳ PR #16795 (rollback require() → static import) — open for review
⏳ PR #16726 (useBridgeTransaction + useAccountBridge) — open
⏳ PR #16752 (isEditableOperation/isStuckOperation async) — draft
⏳ PR #16755 (isAccountEmpty/clearAccount/getVotesCount async) — draft
⏳ LIVE-29186 — getAccountBridge/getCurrencyBridge fully async

13 — What's Next: The Final Flip

Once PR #16795 + the remaining async PRs land, loaders.ts gets one targeted change:

  {
    family: "bitcoin",
-   loadBridge:      () => bitcoinBridge,              // static import — in main bundle
+   loadBridge:      () => import("@ledgerhq/coin-bitcoin/bridge.js"),  // async chunk
-   loadTransaction: () => bitcoinTransaction,
+   loadTransaction: () => import("@ledgerhq/coin-bitcoin/transaction.js").then(m => m.default),
  }

rspack (desktop) emits separate async chunks per family.
Re-Pack (mobile) with lazyImports: true defers Hermes bytecode parsing per family.

App startup
  └── registerAllCoins() — registers lazy references only, zero coin code loaded

User opens Send on a Bitcoin account (first time)
  └── getAccountBridge(btcAccount)
       └── await loadBridgeForFamily("bitcoin")
            └── import("@ledgerhq/coin-bitcoin/bridge.js") — chunk fetched NOW
                 └── bridge returned, cached, Suspense resolves
                      └── all subsequent Bitcoin calls: instant (cache hit)

User never opens a Solana account
  └── @ledgerhq/coin-solana → never loaded, never parsed, never in memory

Projected final metrics

Metric Baseline (before PR #16031) Today (static imports) After final flip
CI desktop tests ~18 min ~18 min ~6 min
LLD renderer bundle baseline baseline −5 MB
LLM bundle baseline baseline −6 MB
Boot time (M1) ~1.4 s ~1.4 s ~1.0 s
ESM-only possible no no yes
Coin isolation none none true: unused coins never run

14 — Remaining Work

Unlock the flip

  1. Land PR [LWDM] fix(live-common): replace require() with static imports in coin-modules loaders #16795 — static imports, clean ESM foundation
  2. Land PR [LWDM] refactor(live-common): useBridgeTransaction async ready #16726useBridgeTransaction + useAccountBridge (Suspense-ready, ~107 files)
  3. Land PR [LWDM] refactor(live-common): make isEditableOperation/isStuckOperation/getStuckAccountAndOperation async #16752isEditableOperation / isStuckOperation async
  4. Land PR [LWDM] refactor(live-common): make isAccountEmpty/clearAccount/getVotesCount async #16755isAccountEmpty / clearAccount / getVotesCount async
  5. Land LIVE-29186 — getAccountBridge / getCurrencyBridge fully async
  6. Reduce non-regression allowlist to []

After the flip

  1. PR [LWD] perf(lld): defer-load renderer coin families and modals via dynamic import() #16216 (LLD) + PR [LWM] feat(live-mobile): defer-load all coin family UI modules #16221 (LLM) — defer-load renderer/families/ UI modules
  2. LIVE-29475/29477 — audit coin-* import leaks in apps (desktop + mobile)
  3. LIVE-29648 — drop CJS builds, live-common ESM-only
  4. Long term: move loaders.ts to app level, each app ships only its families

Q&A Prep

Q: We shipped the require() registry, then we reverted it — what did we actually gain?
The registry architecture is permanent and is the critical piece. The require() loading mechanism was always transitional. We now have: a clean loader interface, non-regression CI enforcement, all coin-* imports removed from generic code, and the entire async call chain prepared. We gave back the 12-min CI temporarily; we get back 6-min (and more) when the final flip lands.

Q: Why not just tree-shake the barrel files?
Tree-shaking removes unused exports. But coin families have module-level side-effects (codec registrations, network client init). Tree-shakers conservatively keep everything. Code-splitting via import() is the only reliable mechanism.

Q: What does the Suspense change mean for users?
On first access of a coin family: ~one paint cycle of loading state (spinner or skeleton). After that: instant, cached. For users who only hold Bitcoin, every other coin never loads at all.

Q: What's the risk of the Suspense activation?
PR #16726 mitigates the biggest risk (cascading blank-screen flash on mount) by using pre-annotated fulfilled Promises. Suspense only fires on genuine first loads of new families, not on every navigation.

Q: What about mobile — will this work with Hermes?
Kevin's lazyImports: true in Re-Pack is exactly the infrastructure needed. asyncChunks: false is already in place to avoid the runtime chunk crash. The mobile path is ready.

Q: What about wallet-cli?
Already ships only 3 families via its own loader config. The long-term app-level loaders.ts pattern is already proven there.


Timeline

Apr 2    Exploration PR #16002 — async import(), 267-file blast radius discovered
Apr 3    Pivot to require() approach — sync API preserved
Apr 9    PR #16031 merged — require() registry, 12-min CI, 1.0s boot
Apr 16   PR #16333 — getAlpacaApi: first real import() in live-common
Apr 16   PR #16408 — Mobile: asyncChunks:false (runtime crash fix)
Apr 20   PRs #16350/51/52 merged — tx/account/platform async prep
Apr 20   PR #16501 — Kevin: activate lazyImports in Re-Pack (LIVE-29415)
Apr 21   PR #16470 merged — all direct coin-* imports removed from shared code
Apr 21   PR #16508 merged — non-regression test suite in CI
Apr 23   PR #16456 merged — getAccountBridge/getCurrencyBridge call sites await-ready
Apr 23   ESM × CJS two-states risk identified → rollback decision
Apr 23   PR #16751 merged — getDeviceTransactionConfig properly awaits loader
Apr 24   PR #16748 merged — wallet-api / platform converters properly await
Apr 24   PR #16753 merged — hw/actions inferCommandParams async
Apr 24   PR #16795 open — rollback require() → static imports
Apr 24   PR #16726 open — useBridgeTransaction Suspense-ready (attempt 2)
         ⏳ next: remaining async PRs → flip loaders to import() → 6-min CI

References

Epic https://ledgerhq.atlassian.net/browse/LIVE-29176
ADR https://ledgerhq.atlassian.net/wiki/spaces/WXP/pages/7049511127
PR #16002 Exploration: full async import() — 267 files, closed
PR #16031 Phase 1: require() registry (merged Apr 9)
PR #16333 getAlpacaApi → async import() (merged Apr 16)
PR #16408 Mobile: asyncChunks:false fix (merged Apr 16)
PR #16501 Mobile: activate Re-Pack lazyImports — Kevin (merged Apr 20)
PR #16470 Remove all coin-* imports from shared code (merged Apr 21)
PR #16508 Non-regression test suite (merged Apr 21)
PR #16456 getAccountBridge/getCurrencyBridge call sites await-ready (merged Apr 23)
PR #16733 Study: full async chain across 267 files — decomposed into sub-PRs
PR #16795 Rollback require() → static imports (open)
PR #16726 useBridgeTransaction + useAccountBridge Suspense-ready (open)
PR #16216 LLD: defer-load renderer families via import()
PR #16221 LLM: defer-load all coin family UI modules

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions