Skip to content
New guideAgent Cookbook 12 copy-ready prompts for AI coding agents.See recipes
All blog posts
web3.js Guide (2026): What It Is, How to Use It With an RPC Endpoint, and When to Use ethers or viem Instead

web3.js Guide (2026): What It Is, How to Use It With an RPC Endpoint, and When to Use ethers or viem Instead

By Ben Chatwin15min read

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)

DateEventSource
3 Dec 2024web3 4.16.0 published, the last releasenpm registry
15 Jan 2025ChainSafe announces the sunset and a phased wind-down through March 2025, with npm access to be transferred to the Ethereum FoundationChainSafe blog
5 Mar 2025web3/web3.js repository archived, read-onlyGitHub
2 to 8 Nov 2026772K weekly downloads for web3, 4.73M for ethers, 9.75M for viemnpm download API
Timeline and bar chart: web3 4.16.0 was the last release on 3 December 2024, ChainSafe announced the sunset on 15 January 2025, the GitHub repository was archived on 5 March 2025, and on 9 November 2026 the package still installs with no deprecation flag. Weekly npm downloads for 2 to 8 November 2026: viem 9.75M, ethers 4.73M, web3.js 772K.

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:

PackageWhat it does
web3-ethBlock, account, transaction and subscription methods
web3-eth-contractThe Contract class: ABI encoding, .call() and .send()
web3-eth-accountsLocal key management and offline signing
web3-providers-http, web3-providers-wsHTTPS and WebSocket transports
web3-utilstoWei, 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 .mjs or 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:

Bash
mkdir web3js-guide && cd web3js-guide
npm init -y
npm install web3 ethers viem

Dwellir's Ethereum endpoints follow one pattern for both transports, documented on the Ethereum quickstart:

Text
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_KEY

Put 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.

JavaScript
// 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');
Text
chain id: 1n latest block: 26376583n
balance: 5.793818533768043911 ETH

Both 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.

JavaScript
// 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);
Text
USDC decimals: 6n total supply: 46097213739n
balance: 39.192124 USDC

Two 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.

JavaScript
// 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));
Text
subscription id: 0xeda91782fac7ebbf756af51a9a3631ca
block 26376584n hash 0xf592ab46...229dc8 baseFee 91624398n
block 26376585n hash 0x85b66982...98fa02 baseFee 84382080n

The 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.

JavaScript
// 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.16ethers v6.17viem 2.57
MaintenanceSunset Jan 2025, repo archived Mar 2025ActiveActive
Last release3 Dec 2024 (4.16.0)18 Jun 2026 (6.17.0)8 Nov 2026 (2.57.17)
Weekly npm downloads772K4.73M9.75M
Minified + gzipped (full import)142 kB137 kB113 kB
Tree-shakable ESMYesYesYes, and designed around it
Number typebigintbigintbigint
TypeScriptWritten in TS, loose contract typesWritten in TS, loose contract typesWritten in TS, ABI-inferred types for contract calls and events
Subscriptionssubscribe() then .on()provider.on('block'), contract.on(event)watchBlocks, watchContractEvent, polling fallback over HTTP
Browser walletnew Web3(window.ethereum)BrowserProvidercreateWalletClient + custom(window.ethereum); wagmi is built on it
Best forExisting codebases with no reason to moveShortest 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

JavaScript
// 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

JavaScript
// 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

JavaScript
// 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

JavaScript
// 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

JavaScript
// 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 situationUseWhy
Existing web3.js app, stable, no active developmentStay on web3.js 4.16, pin it, watch for CVEsMigration cost with no feature gain
Existing web3.js app under active developmentMigrate to ethers v6Closest concept mapping: provider, contract, wallet. Smallest diff
Existing web3.js 1.x app you have not upgradedSkip 4.x, migrate to ethers or viem directlyA 1.x to 4.x upgrade is already a breaking change; do it once
New backend service or indexerviemTyped ABIs, standalone functions, polling fallback for subscriptions
New React frontendviem via wagmiwagmi hooks handle connection, caching and re-rendering on top of viem
You depend on Hardhat plugins or SDKs that take an ethers providerethers v6Interop first
Tutorial from before 2023 that you are followingRewrite it in viemThe 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.

Sources

Start free.1 response = 1 credit.

100,000 responses a day at 20 per second, on 170+ networks. Upgrade when you need more.