Migrating to Web3.js v4: Modular Imports, Async Patterns, and the Hidden Costs
Web3.js v4 cuts bundle size by 80% and adds first-class TypeScript, but three silent breaking changes — missing @web3js/contract, BigInt block numbers, and removed batch requests — cause runtime failures TypeScript won't catch. The migration checklist that prevents production bugs.
29 Mar 2026, 09:04 UTC

The problem: a 500 KB dependency that still uses callbacks
If you've maintained a frontend dApp for more than a year, you've likely shipped web3@1.x — a single ~500 KB package that forces callback-style code, pulls in every Ethereum module whether you use it or not, and leaves TypeScript types as an afterthought. The v4 release (late 2023) rewrites all of that, but the migration path is littered with silent breaking changes that don't appear in the changelog.
Thesis: v4's modular architecture pays off, but only if you audit three specific breaking changes before you flip the switch
The bundle savings are real: importing only @web3js/eth and @web3js/contract typically yields 50–100 KB gzipped. TypeScript inference becomes first-class. The async/await surface eliminates callback pyramids. But three changes — missing peer packages, BigInt block numbers, and removed batch requests — cause runtime failures that TypeScript won't catch.
1. Modular imports: install what you use, nothing more
v1's web3 namespace is gone. Each capability lives in its own scoped package:
@web3js/eth— RPC methods, block/transaction queries@web3js/contract— contract deployment, method calls, event subscriptions@web3js/accounts— key management, signing@web3js/http-provider,@web3js/ws-provider— transport layers
Forgetting @web3js/contract produces a cryptic Contract is not a constructor error at runtime. Verify your package.json includes every module your code touches.
2. Callbacks to async/await: the migration that TypeScript helps with
Every v1 callback becomes a Promise. The pattern is mechanical:
// v1
web3.eth.getBlockNumber((err, number) => {
if (err) return console.error(err);
console.log(number);
});
// v4
import { Web3 } from '@web3js/eth';
const web3 = new Web3('https://mainnet.infura.io/v3/KEY');
const block = await web3.eth.getBlockNumber(); // returns bigint
console.log(block);
Error handling shifts to try/catch. The return type is now bigint — not a string — so block > 18000000n works without coercion.
3. Contract API: new constructor, AsyncIterable events, array filters
The contract entry point moves and the event model changes completely:
// v1
const contract = new web3.eth.Contract(abi, address);
const events = contract.getPastEvents('Transfer', {
filter: { from: sender },
fromBlock: 0
});
// v4
import { Contract } from '@web3js/contract';
import { Web3 } from '@web3js/eth';
const web3 = new Web3(window.ethereum); // EIP-1193 provider
const contract = new Contract(abi, address, { provider: web3.provider });
// Past events: filter values are arrays, block numbers are bigint
const past = await contract.getPastEvents('Transfer', {
filter: { from: [sender] },
fromBlock: 0n
});
// Live events: AsyncIterable, not EventEmitter
for await (const evt of contract.events.Transfer()) {
console.log(evt.args.from, evt.args.to, evt.args.value);
}
Note the [sender] array filter and 0n BigInt literal. The for await loop replaces .on('data', cb) and supports break for cleanup.
Worked example: migrating a token balance check with typed send
Below is a complete, minimal migration of a common pattern — reading a balance and sending a transfer — showing the v1 and v4 equivalents side by side. The v4 version uses generic type parameters on methods.transfer to get compile-time validation of the send options and the TransactionReceipt return type.
// v1 (callback, untyped)
web3.eth.Contract(abi, tokenAddress).methods.balanceOf(user).call((err, bal) => {
if (err) return cb(err);
web3.eth.Contract(abi, tokenAddress).methods.transfer(to, amount)
.send({ from: user, gas: 100000 }, (err, txHash) => cb(err, txHash));
});
// v4 (async, typed)
import { Web3 } from '@web3js/eth';
import { Contract } from '@web3js/contract';
import type { TransactionReceipt } from '@web3js/eth';
const web3 = new Web3(window.ethereum);
const token = new Contract(erc20Abi, tokenAddress, { provider: web3.provider });
const balance = await token.methods.balanceOf(user).call(); // bigint
// Generic params type the send options and receipt events
const receipt = await token.methods.transfer<{ from: string; value: bigint }>(to, amount)
.send({ from: user, gas: 100_000n });
// receipt: TransactionReceipt with typed `events` property
console.log(receipt.events?.Transfer?.args?.value); // bigint
Hover receipt in VS Code to confirm the events property is typed — a feature that only works because each module bundles its own .d.ts files.
Trade-off: batch requests are gone, and peer dependencies will fight you
v4 removes web3.batchRequest(). If your dApp bundles multiple RPC calls (e.g., multicall patterns), you must implement custom batching with Promise.all or adopt a library like viem that exposes JSON-RPC batch multicall. There is no drop-in replacement.
More immediately, tooling hasn't caught up. Hardhat, Truffle, and many Hardhat plugins still declare peerDependency: "web3@^1". Running npm ls web3 in a project that uses both will show conflicts. The workaround is npm install --legacy-peer-deps or maintaining a separate v4-only frontend workspace.
Documentation is another gap. StackOverflow answers and blog posts overwhelmingly cover v1. The official migration guide covers syntax but not the runtime gotchas (BigInt block numbers, array filters, missing @web3js/contract). Budget time for manual testing of every contract interaction.
Actionable closing: verify before you commit
- Create a test bundle:
npm i @web3js/eth @web3js/contract @web3js/accountsthennpx esbuild --bundle index.js --outfile=bundle.js --format=esm --analyzeto confirm the 50–100 KB target. - Check TypeScript inference: write the typed
sendcall above and hover the result in VS Code — ensureTransactionReceipt.eventsis typed, notany. - Run Node 18+:
node --versionmust be ≥18. If you support older environments, factor in polyfills forfetch,AbortController, andstructuredClone— they add ~15 KB. - Test event subscriptions: implement both
for await (const e of contract.events.Transfer())and a legacy.on('data')shim to verify AsyncIterable behavior matches your UI expectations. - Audit peer dependencies:
npm ls web3in your full monorepo. If Hardhat/Truffle appear, plan a separate frontend build or accept--legacy-peer-deps.
If all five checks pass, the migration is low-risk. If any fail, treat them as blockers — the runtime errors they prevent are the kind that surface in production, not CI.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.