Reading Contract State and Events with web3.js: call vs send vs getPastEvents
A practical web3.js guide to choosing between .call(), .send(), and getPastEvents — with version caveats, gas estimation, bounded log queries, and polling vs WebSocket trade-offs.
05 Jul 2026, 23:06 UTC

The decision you're actually making
Every dApp backend eventually hits the same fork: you need data out of a smart contract, and web3.js gives you three different doors — .call(), .send(), and getPastEvents(). Pick the wrong one and you'll either spend gas on reads that should be free, or miss events because your provider can't stream them. This guide walks through a concrete setup: reading an ERC-20 balance, submitting a transfer, and indexing the resulting Transfer events.
Prerequisites
- Node.js 18+ and an installed web3 package (
npm i web3). - A provider endpoint: a local node (Hardhat, Ganache), or an HTTPS/WSS RPC URL for a testnet or mainnet.
- The contract's ABI (the JSON interface description) and deployed address.
- For
.send(): a funded account private key on the target network.
Check your installed major version first — it changes the code you write:
npm ls web3Run this in your project directory. web3.js 1.x returns large integers as BN.js objects; 4.x returns native BigInt. Samples below assume 4.x, with differences flagged.
Step 1: Instantiate the contract abstraction
import { Web3 } from 'web3';
const web3 = new Web3('https://your-rpc-endpoint.example'); // HTTP provider
const contract = new web3.eth.Contract(ERC20_ABI, TOKEN_ADDRESS);The ABI tells web3 how to encode calls and decode results. The address binds the instance to one deployed contract. Neither call nor getPastEvents requires a private key.
Step 2: Read state with .call() — free, local, no receipt
const balance = await contract.methods.balanceOf(HOLDER_ADDRESS).call();
console.log(typeof balance); // 'bigint' on web3 4.x, object (BN) on 1.x.call() executes an eth_call against your node's local state. It is read-only, costs no gas, and returns the method's return values directly. Use it for anything that doesn't change state: balances, configuration, computed views.
Critical check: uint256 values exceed JavaScript's safe integer range. Never do Number(balance) on raw token amounts. Keep them as BigInt (4.x) or BN (1.x), and format for display with web3.utils.fromWei(balance, 'ether') or an equivalent decimal-shift helper. Verify your version's behavior by logging a known balance once before building arithmetic on top of it.
Step 3: Change state with .send() — signed, mined, costs gas
const amount = 1000000n; // raw units as BigInt, never a float
const tx = contract.methods.transfer(RECIPIENT, amount);
const gasEstimate = await tx.estimateGas({ from: SENDER });
const receipt = await tx.send({
from: SENDER,
gas: gasEstimate + gasEstimate / 5n, // +20% safety margin
});.send() signs and broadcasts a real transaction. The promise resolves with the transaction receipt after mining, and the returned emitter fires transactionHash and confirmation events along the way. Two rules: estimate gas rather than hardcoding it (estimation can differ from actual execution, hence the margin), and check receipt.status — a truthy status means success; a falsy one means the transaction was mined but reverted, and you still paid gas.
To sign, the account must be available to web3 — either via a node that holds keys (local dev only) or by adding the key to the wallet: web3.eth.accounts.wallet.add(privateKey). Never commit real keys to source control.
Step 4: Retrieve historical events with getPastEvents
const events = await contract.getPastEvents('Transfer', {
filter: { to: HOLDER_ADDRESS }, // indexed params only
fromBlock: 18_500_000,
toBlock: 18_500_500,
});
for (const e of events) {
console.log(e.returnValues.from, e.returnValues.value); // decoded via ABI
}This wraps eth_getLogs but decodes each log against the ABI, so returnValues gives you named arguments instead of raw hex topics. Always bound fromBlock/toBlock: public RPC endpoints commonly cap the block range or the number of returned logs per request, and an unbounded query on a busy contract will fail or hang. If you need a long history, page through ranges (for example 5,000 blocks at a time) and merge results.
Polling vs subscribing: the real engineering trade-off
For live event tracking you have two options:
| Approach | Provider | Strengths | Risks |
|---|---|---|---|
Poll getPastEvents on an interval | HTTP or WS | Simple, robust to disconnects, stateless retries | Higher latency; rate-limit exposure |
web3.eth.subscribe('logs', ...) | WebSocket or IPC only | Low latency push | Silent drops during reconnects; needs backfill |
HTTP providers cannot hold subscriptions open — attempting subscribe over HTTPS will fail. If you subscribe over WSS, production code must track the last processed block and, on reconnect, backfill the gap with getPastEvents from that block forward. Otherwise you will silently miss events emitted while the socket was down. For many backends, interval polling with a persisted last-block cursor is the simpler correct choice.
Verifying the whole flow
- Against a local node (Hardhat/Ganache) or testnet, call
balanceOfon a known address and confirm the value matches the node's state without any gas being spent. - Send a small
transfer, confirmreceipt.statusis truthy, then re-read the balance and confirm it changed by exactly the sent amount. - Query
getPastEvents('Transfer', ...)over the narrow range containing your transaction's block and confirm the decodedreturnValuesmatch the arguments you sent. - If you use subscriptions: connect via
ws://, trigger an event, confirm the callback fires — then restart the provider and confirm your backfill catches an event emitted during the outage.
Limitations
- API details differ between web3.js 1.x and 4.x (module format, BigInt vs BN, some method names). Match samples to
npm ls web3output before adapting. - Public RPC rate limits and log-range caps vary by provider; test your exact query pattern against your endpoint, not just a local node.
estimateGassimulates against current state; transactions that depend on state changed by earlier pending transactions can still run out of gas.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.