web3.js is sunset. ChainSafe, its maintainer since 2020, announced the end of maintenance on 15 January 2025 and archived the GitHub repository on 5 March 2025. The last release is 4.16.0, published 3 December 2024. The npm package is not flagged as deprecated and still pulled 772,000 downloads in the week of 2 to 8 November 2026, which is why the search traffic for "web3.js" has not gone anywhere even though the library has.
That leaves two groups of people searching for a web3.js tutorial. The first is maintaining a codebase that already uses it and needs to know whether it still works and what the 4.x API looks like. The second is about to start something new and has found a 2022 tutorial. This guide serves both: it explains what web3.js is, shows working 4.x code against a Dwellir Ethereum endpoint, then puts the same operations side by side in ethers v6 and viem with a decision table for which one to pick.
Every read-only snippet below ran on 9 November 2026 with web3@4.16.0, ethers@6.17.0 and viem@2.57.17 on Node 24. The outputs shown are the ones the code printed.
web3.js Maintenance Status (2026)
| Date | Event | Source |
|---|---|---|
| 3 Dec 2024 | web3 4.16.0 published, the last release | npm registry |
| 15 Jan 2025 | ChainSafe announces the sunset and a phased wind-down through March 2025, with npm access to be transferred to the Ethereum Foundation | ChainSafe blog |
| 5 Mar 2025 | web3/web3.js repository archived, read-only | GitHub |
| 2 to 8 Nov 2026 | 772K weekly downloads for web3, 4.73M for ethers, 9.75M for viem | npm download API |

The sunset post spells out the replacements: "amazing libraries like Viem and Ethers.js are thriving and we believe it's time to pass the torch." Migration support was offered on a best-effort basis through Discord and GitHub issues during the wind-down. There is no announced successor maintainer and no fork with meaningful adoption.
What this means in practice: the library works today and will keep working for as long as the Ethereum JSON-RPC surface it depends on stays stable, which is a long time. What it will not get is a fix when a dependency publishes a CVE, a Node release breaks something, or a new transaction type (the way EIP-1559 and EIP-4844 each needed library changes) lands. If you depend on it, you own that risk.
What web3.js Is
web3.js is the original JavaScript client for Ethereum's JSON-RPC API. It began as an Ethereum Foundation project, first published to npm in April 2015, moved to ChainSafe's maintenance in 2020, and was rewritten in TypeScript as version 4.x, first released in June 2023. It wraps the raw RPC methods described in what RPC means in blockchain in typed functions: eth_getBalance becomes web3.eth.getBalance(), eth_call against a contract becomes contract.methods.fn().call(), and eth_subscribe becomes web3.eth.subscribe().
The 4.x line is a monorepo of packages that the umbrella web3 package re-exports. The ones you will touch:
| Package | What it does |
|---|---|
web3-eth | Block, account, transaction and subscription methods |
web3-eth-contract | The Contract class: ABI encoding, .call() and .send() |
web3-eth-accounts | Local key management and offline signing |
web3-providers-http, web3-providers-ws | HTTPS and WebSocket transports |
web3-utils | toWei, fromWei, hex and address helpers |
Four things changed between 1.x and 4.x that break old tutorials. Numbers come back as bigint by default instead of strings or JavaScript numbers. The default export is gone; you import { Web3 } by name. Subscriptions are a two-step await subscribe() then .on('data'), not a single call. The package is ESM with tree-shaking support, so a web3-eth-only import no longer drags in every package.
Prerequisites
- Node.js 18 or later (the snippets use top-level
await, so save them as.mjsor set"type": "module") - A Dwellir API key from the dashboard. The free tier gives 20 responses per second and 100,000 a day, which is enough for everything here
- For the transaction section, a Sepolia address with test ETH from a faucet
Install the three libraries in one go so the migration section runs from the same folder:
mkdir web3js-guide && cd web3js-guide
npm init -y
npm install web3 ethers viemDwellir's Ethereum endpoints follow one pattern for both transports, documented on the Ethereum quickstart:
HTTPS https://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY
WSS wss://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY
Sepolia https://api-ethereum-sepolia.n.dwellir.com/YOUR_API_KEYPut the key in an environment variable rather than in source. The examples read RPC_HTTPS and RPC_WSS.
Connect Over HTTPS and Read Chain State
Passing a URL string to the Web3 constructor creates an HttpProvider. Three reads cover the basics: chain ID (confirms you hit the network you meant), latest block, and an account balance.
// 01_http.mjs
import { Web3 } from 'web3';
const web3 = new Web3(process.env.RPC_HTTPS);
const [blockNumber, chainId] = await Promise.all([
web3.eth.getBlockNumber(),
web3.eth.getChainId(),
]);
console.log('chain id:', chainId, 'latest block:', blockNumber);
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
const wei = await web3.eth.getBalance(address);
console.log('balance:', web3.utils.fromWei(wei, 'ether'), 'ETH');chain id: 1n latest block: 26376583n
balance: 5.793818533768043911 ETHBoth values print with an n suffix because they are bigint. That is the single most common 1.x to 4.x breakage: blockNumber + 1 throws TypeError: Cannot mix BigInt and other types until you write blockNumber + 1n. If you need numbers or hex strings instead, pass a return format as the last argument, for example web3.eth.getBlockNumber({ number: 'NUMBER_NUMBER', bytes: 'BYTES_HEX' }).
Each of those three calls is one JSON-RPC request and one response, so one API credit each on a Dwellir plan. Promise.all fires them concurrently, which matters once you are doing this per block.
Read an ERC-20 Contract With Its ABI
Contract reads go through web3.eth.Contract. You give it a JSON ABI and an address; it encodes the call data, sends eth_call, and decodes the result. The ABI only needs the functions you use, so a four-entry fragment is enough to read USDC on mainnet.
// 02_erc20.mjs
import { Web3 } from 'web3';
const web3 = new Web3(process.env.RPC_HTTPS);
const erc20Abi = [
{ name: 'symbol', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
{ name: 'decimals', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
{ name: 'totalSupply', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
{ name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ type: 'uint256' }] },
];
const usdc = new web3.eth.Contract(erc20Abi, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48');
const [symbol, decimals, supply] = await Promise.all([
usdc.methods.symbol().call(),
usdc.methods.decimals().call(),
usdc.methods.totalSupply().call(),
]);
console.log(symbol, 'decimals:', decimals, 'total supply:', supply / 10n ** BigInt(decimals));
const holder = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045';
const raw = await usdc.methods.balanceOf(holder).call();
console.log('balance:', Number(raw) / 10 ** Number(decimals), symbol);USDC decimals: 6n total supply: 46097213739n
balance: 39.192124 USDCTwo details to watch. decimals() returns 6n, a bigint, so 10 ** decimals fails and you need 10n ** BigInt(decimals) or Number(decimals). And .call() is a read; .send({ from }) is the write path, which needs a signer and is covered below.
Subscribe to New Blocks Over WebSocket
Subscriptions need a persistent connection, so this is where the WSS URL comes in. The 4.x pattern is: construct a WebsocketProvider, await web3.eth.subscribe('newBlockHeaders'), then attach listeners to the returned subscription object. 'newHeads' is an alias and does the same thing.
// 03_ws.mjs
import { Web3 } from 'web3';
const provider = new Web3.providers.WebsocketProvider(
process.env.RPC_WSS,
{},
{ autoReconnect: true, delay: 1000, maxAttempts: 10 },
);
const web3 = new Web3(provider);
const sub = await web3.eth.subscribe('newBlockHeaders');
console.log('subscription id:', sub.id);
let seen = 0;
sub.on('data', (header) => {
console.log('block', header.number, 'hash', header.hash, 'baseFee', header.baseFeePerGas);
if (++seen === 2) sub.unsubscribe().then(() => provider.disconnect());
});
sub.on('error', (err) => console.error('subscription error:', err));subscription id: 0xeda91782fac7ebbf756af51a9a3631ca
block 26376584n hash 0xf592ab46...229dc8 baseFee 91624398n
block 26376585n hash 0x85b66982...98fa02 baseFee 84382080nThe third constructor argument is the reconnect policy. autoReconnect re-opens the socket, but it does not re-create your subscriptions and it does not replay the blocks that arrived while you were disconnected. Geth's own documentation says subscriptions are "probably not the best option" for use cases that cannot miss a notification. The fix is an HTTP backfill from the last header you saw to the current head after every reconnect; WebSockets for blockchain developers walks through that pattern and what subscription limits look like across providers. On Dwellir each pushed header counts as one response, the same unit as an HTTP call.
Send a Signed Transaction on Sepolia
web3.js can sign locally with web3.eth.accounts and broadcast with web3.eth.sendSignedTransaction. Keep the two steps separate; it makes the code portable to a hardware signer or KMS later. This example builds an EIP-1559 transfer on Sepolia.
// 04_send_sepolia.mjs
import { Web3 } from 'web3';
const web3 = new Web3(process.env.RPC_HTTPS_SEPOLIA);
const account = web3.eth.accounts.privateKeyToAccount(process.env.PRIVATE_KEY);
const [chainId, nonce, block] = await Promise.all([
web3.eth.getChainId(),
web3.eth.getTransactionCount(account.address),
web3.eth.getBlock('latest'),
]);
const maxPriorityFeePerGas = web3.utils.toWei('1', 'gwei');
const maxFeePerGas = BigInt(block.baseFeePerGas) * 2n + BigInt(maxPriorityFeePerGas);
const tx = {
from: account.address,
to: '0x000000000000000000000000000000000000dEaD',
value: web3.utils.toWei('0.001', 'ether'),
gas: 21000n,
maxFeePerGas,
maxPriorityFeePerGas,
nonce,
chainId,
type: 2,
};
const signed = await web3.eth.accounts.signTransaction(tx, account.privateKey);
console.log('hash:', signed.transactionHash);
const receipt = await web3.eth.sendSignedTransaction(signed.rawTransaction);
console.log('mined in block', receipt.blockNumber, 'status', receipt.status);Awaiting sendSignedTransaction resolves to the receipt once the transaction is mined. If you want progress events, drop the await and chain .on('transactionHash', ...), .on('receipt', ...) and .on('error', ...) on the returned PromiEvent instead. The signing half of this script ran against Dwellir's Sepolia endpoint with a throwaway key (chain ID 11155111n, nonce 0n, a 240-character raw transaction); broadcasting needs a funded address, so get Sepolia ETH from a faucet before running it end to end.
Setting maxFeePerGas to twice the current base fee plus the tip is a simple ceiling that survives a few blocks of fee increase. For how the base fee and tip interact, see Ethereum gas fees explained.
web3.js vs ethers.js vs viem
The reason to compare is not that web3.js is bad code. The 4.x rewrite is a solid TypeScript library. The reason is that a library with no maintainer accumulates risk, and the two alternatives are both maintained and more widely used. Figures below are from npm and Bundlephobia, checked 9 November 2026.
| web3.js 4.16 | ethers v6.17 | viem 2.57 | |
|---|---|---|---|
| Maintenance | Sunset Jan 2025, repo archived Mar 2025 | Active | Active |
| Last release | 3 Dec 2024 (4.16.0) | 18 Jun 2026 (6.17.0) | 8 Nov 2026 (2.57.17) |
| Weekly npm downloads | 772K | 4.73M | 9.75M |
| Minified + gzipped (full import) | 142 kB | 137 kB | 113 kB |
| Tree-shakable ESM | Yes | Yes | Yes, and designed around it |
| Number type | bigint | bigint | bigint |
| TypeScript | Written in TS, loose contract types | Written in TS, loose contract types | Written in TS, ABI-inferred types for contract calls and events |
| Subscriptions | subscribe() then .on() | provider.on('block'), contract.on(event) | watchBlocks, watchContractEvent, polling fallback over HTTP |
| Browser wallet | new Web3(window.ethereum) | BrowserProvider | createWalletClient + custom(window.ethereum); wagmi is built on it |
| Best for | Existing codebases with no reason to move | Shortest migration from web3.js; ethers-provider ecosystems (Hardhat, many SDKs) | New projects, frontends with wagmi, anyone who wants typed ABIs |
The bundle sizes are closer than most comparisons suggest; viem's advantage shows up when you import only what you use, because its functions are standalone rather than methods on a client class. The type-safety difference is bigger: viem infers argument and return types from a const ABI, so readContract({ functionName: 'balanceOf' }) knows it needs one address argument and returns bigint at compile time. web3.js and ethers both type contract methods as any-adjacent unless you add a codegen step.
Migrating From web3.js: Side-by-Side Snippets
Each block below does the same thing in the three libraries and ran against the same Dwellir endpoint. The web3.js version is the code from earlier in this guide.
Connect to an endpoint
// web3.js
import { Web3 } from 'web3';
const web3 = new Web3(process.env.RPC_HTTPS);
// ethers v6
import { JsonRpcProvider } from 'ethers';
const provider = new JsonRpcProvider(process.env.RPC_HTTPS);
// viem
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({ chain: mainnet, transport: http(process.env.RPC_HTTPS) });Block number and balance
// web3.js
const block = await web3.eth.getBlockNumber(); // 26376583n
const wei = await web3.eth.getBalance(address);
console.log(web3.utils.fromWei(wei, 'ether')); // 5.793818533768043911
// ethers v6
import { formatEther } from 'ethers';
const block = await provider.getBlockNumber(); // 26376585 (number)
const wei = await provider.getBalance(address); // bigint
console.log(formatEther(wei));
// viem
import { formatEther } from 'viem';
const block = await client.getBlockNumber(); // 26376587n
const wei = await client.getBalance({ address });
console.log(formatEther(wei));One trap: ethers returns getBlockNumber() as a plain number while balances are bigint. web3.js and viem return bigint for both.
ERC-20 read
// web3.js
const usdc = new web3.eth.Contract(erc20Abi, USDC);
const raw = await usdc.methods.balanceOf(holder).call();
// ethers v6 (human-readable ABI is allowed)
import { Contract } from 'ethers';
const usdc = new Contract(USDC, ['function balanceOf(address) view returns (uint256)'], provider);
const raw = await usdc.balanceOf(holder);
// viem (ships a standard erc20Abi)
import { erc20Abi } from 'viem';
const raw = await client.readContract({ address: USDC, abi: erc20Abi, functionName: 'balanceOf', args: [holder] });All three printed 39.192124 USDC for the same holder at the same block height.
New block subscription
// web3.js (needs WebsocketProvider)
const sub = await web3.eth.subscribe('newBlockHeaders');
sub.on('data', (h) => console.log(h.number));
// ethers v6
import { WebSocketProvider } from 'ethers';
const ws = new WebSocketProvider(process.env.RPC_WSS);
ws.on('block', (n) => console.log(n));
// viem
import { webSocket } from 'viem';
const wsClient = createPublicClient({ chain: mainnet, transport: webSocket(process.env.RPC_WSS) });
const unwatch = wsClient.watchBlocks({ onBlock: (b) => console.log(b.number) });viem's watchBlocks also works over an http() transport by polling, which is useful in serverless environments that cannot hold a socket. Neither ethers nor viem backfills missed blocks after a reconnect either; the backfill logic is yours in all three.
Sign and send
// web3.js
const signed = await web3.eth.accounts.signTransaction(tx, privateKey);
const receipt = await web3.eth.sendSignedTransaction(signed.rawTransaction);
// ethers v6
import { Wallet, parseEther } from 'ethers';
const wallet = new Wallet(privateKey, provider);
const response = await wallet.sendTransaction({ to, value: parseEther('0.001') });
const receipt = await response.wait();
// viem
import { createWalletClient, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { sepolia } from 'viem/chains';
const wallet = createWalletClient({ account: privateKeyToAccount(privateKey), chain: sepolia, transport: http(process.env.RPC_HTTPS_SEPOLIA) });
const hash = await wallet.sendTransaction({ to, value: parseEther('0.001') });
const receipt = await client.waitForTransactionReceipt({ hash });ethers and viem fill in nonce, chain ID and EIP-1559 fees for you. web3.js can too if you call web3.eth.sendTransaction with a wallet added to web3.eth.accounts.wallet, but the explicit version above is what most 1.x-era codebases contain, so it is the code you will be translating.
Which Library Should You Use?
| Your situation | Use | Why |
|---|---|---|
| Existing web3.js app, stable, no active development | Stay on web3.js 4.16, pin it, watch for CVEs | Migration cost with no feature gain |
| Existing web3.js app under active development | Migrate to ethers v6 | Closest concept mapping: provider, contract, wallet. Smallest diff |
| Existing web3.js 1.x app you have not upgraded | Skip 4.x, migrate to ethers or viem directly | A 1.x to 4.x upgrade is already a breaking change; do it once |
| New backend service or indexer | viem | Typed ABIs, standalone functions, polling fallback for subscriptions |
| New React frontend | viem via wagmi | wagmi hooks handle connection, caching and re-rendering on top of viem |
| You depend on Hardhat plugins or SDKs that take an ethers provider | ethers v6 | Interop first |
| Tutorial from before 2023 that you are following | Rewrite it in viem | The web3.js 1.x code it shows will not run against 4.x anyway |
Whatever library you pick, the endpoint behind it is the same JSON-RPC surface. Dwellir's Ethereum endpoint serves HTTPS and WSS from one URL pattern, counts one response as one credit on every plan, and includes debug and trace methods at the same rate. Plans run from the free tier at 20 responses per second to $999 a month for 500M responses at 5,000 per second; see pricing and the Ethereum network page for the full list of what the endpoint supports.
Where to Go From Here
If you are maintaining web3.js code, the official docs still host a migration guide to ethers and a migration guide to viem, both written by the web3.js team during the wind-down. They cover providers, accounts, contracts and events in the same side-by-side format as the snippets above.
If you are starting new, take the viem snippets from this post, swap in your own key, and you have a working read path against Ethereum in about 20 lines. The Ethereum quickstart has the same connection code for ethers and web3.py, and get a free API key to run it.




