The @alephium/web3 TypeScript SDK is how developers connect to and interact with the Alephium blockchain. It’s similar to what viem is for Ethereum. It provides utilities like address validation, transaction signing, smart contract interaction, and API communication.

But it had a problem. A big, 742 kB problem.

The starting point

A developer who imported a single function from our SDK, say, isValidAddress, paid for the entire library. A plain website with one text input and one SDK call produced a 742 kB JavaScript bundle. A React app? 928 kB. Getting it to work in React Native required 12 workarounds including custom Metro resolvers, native crypto modules, and a dev build (Expo Go wouldn’t work).

The root causes were typical of TypeScript libraries built a few years ago:

  • No ESM output — strictly CommonJS, defeating tree-shaking
  • Monolithic UMD browser bundle — webpack squashed everything into one opaque file
  • Heavy Node.js polyfillscrypto-browserify, stream-browserify, buffer shipped as dependencies
  • Redundant crypto libraries — both elliptic and @noble/secp256k1
  • A global side effectBigInt.prototype.toJSON was monkey-patched on import, making "sideEffects": false impossible

Before I touched any code: benchmarking

Before changing anything, I built four benchmark apps. The idea was to have the same code running in Node.js, a Vite website, a Vite + React webapp, and an Expo (React Native) app. Each one imported isValidAddress, validated an address, and fetched a balance. This gave me concrete “before” numbers to measure against.

The Expo app was the most revealing. Getting it to work required: a custom entry point, a Buffer polyfill, react-native-quick-crypto (because crypto-browserify crashed at module evaluation time), readable-stream, path-browserify, events, process, an empty fs shim, a custom Metro resolver to bypass the UMD bundle (which referenced self, undefined in React Native), extraNodeModules for 6 builtins, node-linker=hoisted for pnpm, and expo-dev-client because Expo Go couldn’t handle the native crypto module.

That’s 12 workarounds just to call one function.

Phase 1: The diet

The first phase was about removing what we no longer need, without changing the build system.

Bumping Node.js to >= 20

Node 14 was the minimum. Node 14 is EOL. Bumping to 20 unlocked native fetch (goodbye cross-fetch) and globalThis.crypto.subtle (goodbye Node crypto imports). I originally tried Node >= 18 but discovered that crypto.subtle is undefined on Node 18 in non-secure contexts. But since Node 18 is also EOL, Node 20 was a better choice.

Consolidating on @noble

The SDK used both elliptic (legacy, huge, pulls in bn.js) and @noble/secp256k1 (modern, audited, zero-dependency). I replaced elliptic with @noble/secp256k1 for secp256k1, added @noble/curves for P-256 and Ed25519, and replaced blakejs with @noble/hashes/blake2b.

For the wallet package, I replaced bip39 with @scure/bip39 and bip32 with @scure/bip32, both from the same @noble/@scure ecosystem that viem uses. This eliminated the entire noble-wrapper.ts adapter file that existed solely to bridge bip32’s API with @noble.

Removing the BigInt monkey-patch

The BigInt.prototype.toJSON mutation was a global side effect. Any consumer importing anything from the SDK got this prototype modification. It made declaring "sideEffects": false impossible, which in turn made tree-shaking impossible.

I replaced it with a stringify utility (inspired by viem’s approach), which is a drop-in JSON.stringify replacement that converts BigInt to strings via a replacer function. The swagger-typescript-api template was updated to use stringify automatically in the generated TypeScript definition API files.

Dependencies went from 11 to 6 for @alephium/web3, and from 8 to 4 for @alephium/web3-wallet. Huge win already 💪

Phase 2: The build system journey

This is where things got interesting and where I learnt the most.

Attempt 1: tsup

My first idea was to use tsup. tsup uses esbuild under the hood and promises dead-simple dual CJS/ESM output. I got it working, but hit problems:

  • bundle: true inlined all dependencies into a single file, which is the opposite of what I wanted for tree-shaking
  • bundle: false with .cjs/.mjs extensions broke Node’s CJS directory resolution (require('./api') doesn’t check .cjs files)
  • bundle: false with .js/.mjs extensions broke Vite’s dev server because bare import paths like "./api" in .mjs files resolved to .js (CJS) instead of .mjs

Attempt 2: tsup with workarounds

I tried various combinations: explicit file paths in barrel exports, resolve.conditions in Vite config, optimizeDeps.include, bundled ESM with externalized deps. Each fix introduced a new problem.

The solution: tsc dual-build (like viem)

I looked at how viem does it. They don’t use tsup or any build tool wrapper. They simply use plain tsc twice:

  1. CJS pass: tsc --module commonjs --outDir dist/_cjs + {"type":"commonjs"} in dist/_cjs/package.json
  2. ESM pass: tsc --outDir dist/_esm + {"type":"module","sideEffects":false} in dist/_esm/package.json

That’s quite a smart a clean approach 🧠

All files use .js extension. The nested package.json determines interpretation. No extension confusion, no resolver hacks.

But I hit one more issue: strict ESM resolvers (Vitest, Node.js with "type": "module") require explicit .js extensions in import paths. tsc doesn’t rewrite import specifiers — from './api' stays as from './api' in the output, which fails in strict ESM because there’s no ./api.js file (it’s ./api/index.js). The solution to this woudl require a massive refactoring of all import statements around the code base. It didn’t feel right to me to have a TS codebase full of .js imports (even though that’s what viem does).

My solution: I decided to use tsc-alias as a post-build tool that rewrites the ESM output to add .js extensions (./api./api/index.js). Source TS files stay clean. CJS output doesn’t need it. One line added to the build script.

Phase 3: Package configuration

Getting the package.json right is surprisingly nuanced:

{
  "type": "commonjs",
  "sideEffects": false,
  "main": "dist/_cjs/index.js",
  "module": "dist/_esm/index.js",
  "types": "dist/_cjs/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "default": "./dist/_esm/index.js",
        "types": "./dist/_esm/index.d.ts"
      },
      "require": {
        "default": "./dist/_cjs/index.js",
        "types": "./dist/_cjs/index.d.ts"
      }
    },
    "./api/explorer": { "..." },
    "./api/node": { "..." }
  }
}

Each field serves a different consumer: main for legacy CJS, module for legacy bundlers, types for TypeScript with moduleResolution: "node", and exports for everything modern. The import/require conditions each have their own types entry: CJS declarations in _cjs/, ESM declarations in _esm/. This avoids the FalseCJS type issue.

I added publint and @arethetypeswrong/cli to validate the configuration. Not even viem passes attw for strict node16 ESM resolution. It’s a known limitation of dual CJS/ESM packages when tsc doesn’t rewrite import paths in .d.ts files. I decided to ignore node16 and move forward.

TypeScript 5.9

Upgrading from TypeScript 4.9 to 5.9 across the monorepo was straightforward but gave me moduleResolution: "bundler" which properly resolves exports fields in package.json. I also migrated from Jest to Vitest, which handles ESM natively. No more transformIgnorePatterns hacks for @noble and @scure packages. However, upgrading @noble/hashes and @noble/curves to v2 (ESM-only) is still blocked by the dual CJS/ESM build: the CJS build uses --moduleResolution node which can’t resolve subpath exports from ESM-only packages. That upgrade waits for either dropping CJS or bumping to Node 22+ with --module nodenext. Node 20 reaches EOL in April 2026 (current month as of writing). So, soon I can upgrade those libraries to v2.

The results

I tested with the actual benchmark apps, using packages published to a local Verdaccio registry (exactly as consumers experience from npm):

Appv2.0.10v3.0.0Reduction
Website (vanilla JS)1,697 kB207 kB-88%
Webapp (React)1,904 kB407 kB-79%

And in the real production apps from the alephium-frontend monorepo:

AppBeforeAfterReduction
Explorer: index.js chunk2,480 kB730 kB-71%
Desktop wallet7,740 kB4,944 kB-36%
Mobile wallet (iOS)22.5 MB21.3 MB-5.3%

The explorer also removed rollup-plugin-node-polyfills since it’s no longer needed. The mobile wallet’s improvement is more modest because the SDK is a smaller fraction of the total React Native bundle.

For Expo/React Native, the setup went from 12 workarounds to 2: react-native-get-random-values (for @noble/secp256k1) and an empty fs shim (Metro resolves dynamic imports statically). Expo Go works and no dev build required.

A colleague rightfully questioned the need for an empty fs shim. The reason it is needed is that the SDK uses the Node.js builtin fs module for 2 functions. As long as these 2 functions are not consumed, the Vite project or the vanilla JS website project do not complain. The Metro bundler of the Expo project, however, imports everything at ones before doing the static analysis, and it throws an exception, since fs is not available. Creating an empty shim solved this problem. After some more investigation, I decided to follow the isomorphic-git example, by simply providing the required method of the fs module (in my case that was readFile) as a parameter to these 2 functions that required the fs module. That way, any consumer of those 2 functions will simply have to pass fs.readFile as an argument to them. This completely eliviated the need for a shim, leading to smoother devX when integrating the SDK.

What I’d do differently

Start with tsc, not tsup. I spent significant time debugging tsup’s ESM resolution quirks before realizing that viem’s plain-tsc approach avoids them entirely. Build tool wrappers add convenience but also abstraction. When something breaks, you’re debugging the wrapper’s behavior, not your code.

Benchmark with published packages from day one. I initially tested with pnpm link and tarball installs, which behave differently from real npm installs. Setting up Verdaccio early would have caught issues sooner. Frontend development with multiple shared local packages is still pain.

Don’t underestimate package.json complexity. Getting exports, types, main, module, type, and sideEffects right for dual CJS/ESM is genuinely hard. Tools like publint and attw help, but even the reference libraries (viem) don’t pass every check.

What’s next

  • Drop CJS or bump to Node 22+. This will unlock @noble v2 / @scure v2 (ESM-only packages), currently blocked by the CJS build’s --moduleResolution node
  • Sub-path exports@alephium/web3/codec, @alephium/web3/address, etc. for even more granular tree-shaking

The full roadmap, benchmark data, and reference apps are available in the web3-rewrite repo. Lastly, I wrote a migration guide that explains how to go from v2 to v3.