Read with .call(), write with .send(): best practice for web3.js contract access
Separate read‑only contract access from state changes in web3.js. Use .call() for reads and .send() for writes, with explicit version notes, example code, and limitations.
13 Jun 2026, 07:53 UTC

The problem is mixing reads and writes in the same code path
A common UI bug in dApps built with web3.js is treating every contract method the same. A view function is called with .send(), which creates a signed transaction, waits for mining, and costs the user gas. A state‑changing function is called with .call(), which simulates locally and never updates chain state. The useful takeaway is to bind intent to the web3.js API: use .call() for read‑only simulation and .send() for state changes, and make the version you are on explicit.
web3.eth.Contract gives typed access, but call vs send is your decision
web3.eth.Contract binds an ABI and address to an instance. From there methods.myFn() returns a request object with .call() and .send().
.call() executes eth_call against a node. It simulates execution at the latest block, returns decoded return data, and does not create a transaction. It is suitable for UI data fetching.
.send() creates a signed transaction via the provider. web3.js handles nonce management through the provider and supports EIP‑1559 fields maxFeePerGas and maxPriorityFeePerGas when supplied. The call is asynchronous and produces a transaction hash and later a receipt.
API shape differs between web3.js v1.x and v4.x. Provider initialization, import paths, and some method names changed. Code must match the major version in use. Check package.json exports for eth.Contract and BatchRequest before copying examples.
Concrete split for a token balance and transfer
Run the following in a Node.js process with RPC access. It requires read‑only RPC permission for the call path and a signer with funds and a private key for the send path.
// v1.x style initialization
const Web3 = require('web3');
const web3 = new Web3(new Web3.providers.HttpProvider('<RPC_URL>'));
const abi = [ /* ERC20 ABI fragment for balanceOf and transfer */ ];
const contractAddress = '<CONTRACT_ADDRESS>';
const contract = new web3.eth.Contract(abi, contractAddress);
// Read path: no transaction, no gas
async function getBalance(owner) {
const result = await contract.methods.balanceOf(owner).call();
// Expected check: result is a decimal string, not a transaction hash
return result;
}
// Write path: creates transaction
async function transfer(to, amount) {
const from = '<SENDER_ADDRESS>';
const tx = contract.methods.transfer(to, amount)
.send({
from,
maxFeePerGas: '2000000000',
maxPriorityFeePerGas: '1000000000'
});
// Expected checks: tx.on('transactionHash') emits hash, receipt.status indicates success
return tx;
}
For encoding without a contract instance you can use the ABI coder directly:
const data = web3.eth.abi.encodeFunctionCall(
{ name: 'transfer', type: 'function', inputs: [...] },
['0x...', '1000000000000000000']
);
Decode with web3.eth.abi.decodeParameters to verify the ABI coder behavior matches your expectations.
Trade‑offs and limitations to plan for
.call() does not guarantee up‑to‑date state on nodes with caching or when the node is syncing. For critical reads confirm node sync status or specify a block tag.
Gas estimation can fail on contracts with revert‑only paths or when the call depends on msg.sender. In that case provide a manual gas limit and expect the transaction to revert if the estimate was wrong.
Event subscriptions via web3.eth.subscribe and contract event filters are provider dependent. Websocket providers may drop subscriptions. Implement reconnection logic and use filters with fromBlock and topics to limit reorg risk and RPC load.
Sending transactions requires custody of a private key. Never run send paths in untrusted environments. Use a local dev chain for testing the populated EIP‑1559 fields in the signed raw transaction before mainnet.
Actionable closing
Keep two explicit helpers in your codebase: readContract and writeContract. Route view/pure functions through .call() and state‑changing functions through .send() with explicit gas and fee fields. Pin web3.js major version in package.json and document which initialization pattern you use. Verify the split by inspecting that read calls return decoded values with no hash, and write calls return a transaction hash and a receipt with status.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.