# Ethers.js vs Web3.js — Which Library Should You Use?
When building Ethereum dApps, you need a JavaScript library to interact with the blockchain. The two dominant choices are Ethers.js and Web3.js. Both enable wallet connections, contract interactions, and transaction management — but they differ significantly in design philosophy, bundle size, and developer experience. This guide compares both libraries to help you choose the right one for your project.
Quick Comparison Table
| Feature | Ethers.js | Web3.js |
|---------|-----------|---------|
| Bundle size | 116 KB (minified) | 600+ KB (minified) |
| API design | Modern, clean, TypeScript-first | Legacy, callback-heavy |
| TypeScript | Full native support | Added via @types/web3 |
| Maintenance | Actively maintained | Slower updates |
| Documentation | Excellent | Good but scattered |
| Learning curve | Moderate | Steeper |
| Provider management | Simple (BrowserProvider) | Complex (Web3.providers) |
| ENS support | Built-in | Via separate package |
| BigNumber | Native BigInt + custom BigNumber | Custom BigNumber |
| Community | Growing fast | Established |
| Best for | New projects, production dApps | Legacy projects, Web3.js v4 |
Installation
Ethers.js v6
npm install ethers
import { BrowserProvider, Contract } from 'ethers';
Web3.js v4
npm install web3
import { Web3 } from 'web3';
Connecting to Ethereum
Ethers.js
import { BrowserProvider } from 'ethers';
// Connect to MetaMask
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();
console.log('Connected:', address);
Clean separation:
Provider: Read-only access to blockchain
Signer: Write access (transactions, signing)
Web3.js
import { Web3 } from 'web3';
// Connect to MetaMask
const web3 = new Web3(window.ethereum);
const accounts = await web3.eth.requestAccounts();
const address = accounts[0];
console.log('Connected:', address);
Less explicit separation between read/write operations.
Reading Blockchain Data
Ethers.js
// Get balance
const balance = await provider.getBalance(address);
console.log('Balance:', ethers.formatEther(balance), 'ETH');
// Get block number
const blockNumber = await provider.getBlockNumber();
// Get gas price
const feeData = await provider.getFeeData();
console.log('Gas price:', ethers.formatUnits(feeData.gasPrice, 'gwei'), 'gwei');
// Get transaction
const tx = await provider.getTransaction(txHash);
Web3.js
// Get balance
const balance = await web3.eth.getBalance(address);
console.log('Balance:', web3.utils.fromWei(balance, 'ether'), 'ETH');
// Get block number
const blockNumber = await web3.eth.getBlockNumber();
// Get gas price
const gasPrice = await web3.eth.getGasPrice();
console.log('Gas price:', web3.utils.fromWei(gasPrice, 'gwei'), 'gwei');
// Get transaction
const tx = await web3.eth.getTransaction(txHash);
Very similar API, but different utility function namespaces.
Sending Transactions
Ethers.js
const signer = await provider.getSigner();
const tx = await signer.sendTransaction({
to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
value: ethers.parseEther('0.1')
});
console.log('Transaction hash:', tx.hash);
// Wait for confirmation
const receipt = await tx.wait();
console.log('Confirmed in block:', receipt.blockNumber);
tx.wait() returns a promise that resolves when the transaction is mined.
Web3.js
const accounts = await web3.eth.getAccounts();
const tx = await web3.eth.sendTransaction({
from: accounts[0],
to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
value: web3.utils.toWei('0.1', 'ether')
});
console.log('Transaction hash:', tx.transactionHash);
console.log('Confirmed in block:', tx.blockNumber);
Web3.js returns the receipt directly (no separate wait() call).
Contract Interaction
Ethers.js
import { Contract } from 'ethers';
const abi = [
"function balanceOf(address owner) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)"
];
const contract = new Contract(contractAddress, abi, signer);
// Read function (no gas)
const balance = await contract.balanceOf(address);
console.log('Balance:', ethers.formatUnits(balance, 18));
// Write function (costs gas)
const tx = await contract.transfer('0x...', ethers.parseUnits('10', 18));
await tx.wait();
console.log('Transfer complete');
Human-readable ABI — you can define interfaces as strings instead of JSON.
Web3.js
const abi = [
{
"name": "balanceOf",
"type": "function",
"inputs": [{"name": "owner", "type": "address"}],
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "view"
},
{
"name": "transfer",
"type": "function",
"inputs": [
{"name": "to", "type": "address"},
{"name": "amount", "type": "uint256"}
],
"outputs": [{"name": "", "type": "bool"}]
}
];
const contract = new web3.eth.Contract(abi, contractAddress);
// Read function
const balance = await contract.methods.balanceOf(address).call();
console.log('Balance:', web3.utils.fromWei(balance, 'ether'));
// Write function
const accounts = await web3.eth.getAccounts();
await contract.methods.transfer('0x...', web3.utils.toWei('10', 'ether'))
.send({ from: accounts[0] });
JSON ABI required — more verbose, but compatible with all tools.
Event Listening
Ethers.js
// Listen to Transfer events
contract.on('Transfer', (from, to, amount, event) => {
console.log(Transfer from ${from} to ${to}: ${ethers.formatUnits(amount, 18)} tokens);
});
// Query past events
const filter = contract.filters.Transfer(null, myAddress);
const events = await contract.queryFilter(filter, startBlock, endBlock);
Clean event API with filters.
Web3.js
// Listen to Transfer events
contract.events.Transfer()
.on('data', (event) => {
console.log(Transfer from ${event.returnValues.from} to ${event.returnValues.to});
});
// Query past events
const events = await contract.getPastEvents('Transfer', {
filter: { to: myAddress },
fromBlock: startBlock,
toBlock: endBlock
});
More verbose event handling.
BigNumber Handling
Ethers.js
Ethers.js v6 uses native JavaScript BigInt for most operations, with a custom BigNumber class for advanced needs:
const amount = ethers.parseEther('1.5'); // BigInt: 1500000000000000000n
const formatted = ethers.formatEther(amount); // "1.5"
// Math operations
const doubled = amount * 2n; // Native BigInt
Web3.js
Web3.js uses a custom BigNumber implementation:
const amount = web3.utils.toWei('1.5', 'ether'); // "1500000000000000000"
const formatted = web3.utils.fromWei(amount, 'ether'); // "1.5"
// Math operations require BigInt conversion
const doubled = (BigInt(amount) * 2n).toString();
Web3.js returns strings for large numbers, requiring manual conversion for math.
Bundle Size
Ethers.js: ~116 KB minified
Web3.js: ~600 KB minified
For production apps, bundle size matters:
- Ethers.js is 5x smaller, leading to faster load times
- Critical for mobile users and performance-sensitive dApps
TypeScript Support
Ethers.js
Native TypeScript — written in TypeScript from the ground up:
import { BrowserProvider, Contract, Signer } from 'ethers';
const provider: BrowserProvider = new BrowserProvider(window.ethereum);
const signer: Signer = await provider.getSigner();
Full type inference, no @types package needed.
Web3.js
Added via @types/web3 — requires separate type definitions:
npm install --save-dev @types/web3
import { Web3 } from 'web3';
const web3: Web3 = new Web3(window.ethereum);
Types are less precise and can be outdated.
ENS (Ethereum Name Service) Support
Ethers.js
Built-in ENS support — resolve names to addresses automatically:
const address = await provider.resolveName('vitalik.eth');
// "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
const name = await provider.lookupAddress(address);
// "vitalik.eth"
// Use ENS directly in transactions
const tx = await signer.sendTransaction({
to: 'vitalik.eth',
value: ethers.parseEther('0.1')
});
Web3.js
Requires separate package (web3-eth-ens):
npm install web3-eth-ens
const address = await web3.eth.ens.getAddress('vitalik.eth');
Less integrated, more setup required.
Migration Guide: Web3.js → Ethers.js
Provider Setup
Before (Web3.js):
const web3 = new Web3(window.ethereum);
After (Ethers.js):
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
Getting Balance
Before:
const balance = await web3.eth.getBalance(address);
const eth = web3.utils.fromWei(balance, 'ether');
After:
const balance = await provider.getBalance(address);
const eth = ethers.formatEther(balance);
Contract Interaction
Before:
const contract = new web3.eth.Contract(abi, address);
const balance = await contract.methods.balanceOf(owner).call();
After:
const contract = new Contract(address, abi, provider);
const balance = await contract.balanceOf(owner);
Sending Transactions
Before:
await contract.methods.transfer(to, amount).send({ from: account });
After:
const tx = await contract.transfer(to, amount);
await tx.wait();
Which Should You Choose?
Choose Ethers.js if:
- You're starting a new project
- Bundle size matters (mobile, performance)
- You use TypeScript
- You want modern, clean API
- ENS support is needed
- You prefer active maintenance and updates
Choose Web3.js if:
- You're maintaining legacy code
- Your team is already familiar with Web3.js
- You need compatibility with old tools
- You prefer the established ecosystem
- You're migrating to Web3.js v4 (much improved)
Conclusion
Ethers.js is the modern choice for most new projects. Its smaller bundle size, cleaner API, native TypeScript support, and built-in ENS make it ideal for production dApps. Web3.js remains viable for legacy projects, but Ethers.js has become the de facto standard for new development.
Start building with Ethers.js today — integrate it into your dApp and experience the difference. Practice blockchain interactions with Solingo's hands-on Web3 challenges to master both libraries.
Next steps:
- Migrate an existing Web3.js project to Ethers.js
- Build a dApp using Ethers.js from scratch
- Compare bundle sizes in your production build
- Explore advanced features like multicall and custom providers