How to Handle RPC Errors in Web3: A Production Guide
Your dApp will hit RPC errors. Some are your fault, some are the provider’s fault, some are the blockchain’s fault. Knowing which is which (and how to respond to each) is what separates an application that fails gracefully from one that shows users a raw stack trace.
This guide covers every category of RPC error you will encounter in production, what causes each, and the correct handling pattern.
How ethers.js Reports Errors
In ethers.js v6, errors have a code property that tells you the category of failure. Always check error.code first:
try {
const tx = await contract.someFunction();
} catch (error) {
console.log('Error code:', error.code);
console.log('Error message:', error.message);
console.log('Error data:', error.data); // ABI-encoded revert reason if available
}
Category 1: Network Errors
These errors mean your application could not reach the RPC endpoint at all.
| Code | Meaning |
|---|---|
NETWORK_ERROR | Could not connect to the RPC endpoint |
TIMEOUT | Request took too long, connection dropped |
SERVER_ERROR | Provider returned a 5xx HTTP error |
try {
await provider.getBlockNumber();
} catch (error) {
if (error.code === 'NETWORK_ERROR') {
// RPC unreachable — try fallback provider
console.error('Primary RPC unreachable. Switching to fallback.');
return await fallbackProvider.getBlockNumber();
}
if (error.code === 'TIMEOUT') {
// Request timed out — retry with backoff
await delay(1000);
return await provider.getBlockNumber();
}
}
Root cause: Provider outage, network connectivity, DNS failure, rate limiting returning a 429 (some providers do this as a server error).
Fix: Retry with exponential backoff for transient issues. Switch to fallback provider for sustained outages.
Category 2: Transaction Reverted
A transaction was submitted to the network but the smart contract rejected it.
| Code | Meaning |
|---|---|
CALL_EXCEPTION | Contract call reverted |
ACTION_REJECTED | User rejected the transaction (wallet) |
UNPREDICTABLE_GAS_LIMIT | Gas estimation failed because call would revert |
try {
const tx = await contract.transfer(recipient, amount);
await tx.wait();
} catch (error) {
if (error.code === 'CALL_EXCEPTION') {
// Contract reverted — decode the reason
if (error.reason) {
console.error('Revert reason:', error.reason);
// e.g., "ERC20: transfer amount exceeds balance"
} else if (error.data) {
// Try to decode custom error
const decoded = contract.interface.parseError(error.data);
console.error('Custom error:', decoded?.name, decoded?.args);
} else {
console.error('Transaction reverted without reason');
}
}
if (error.code === 'ACTION_REJECTED') {
// User cancelled in their wallet — no action needed
console.log('User cancelled transaction');
}
}
Simulating Before Sending
Use staticCall to simulate a transaction before broadcasting. Catches reverts before you spend gas:
async function safeExecute(contract, method, args) {
try {
// Dry run — does not broadcast
await contract[method].staticCall(...args);
} catch (error) {
// Transaction would revert — surface the reason to user before wasting gas
throw new Error(`Transaction would fail: ${error.reason || error.message}`);
}
// Safe to execute
return await contract[method](...args);
}
Category 3: Transaction Infrastructure Errors
These errors occur before a transaction reaches the contract.
| Error | Meaning |
|---|---|
| Nonce too low | A transaction with this nonce was already mined |
| Nonce too high | Gap in nonce sequence (pending tx missing) |
| Replacement transaction underpriced | Trying to replace a pending tx with too low a fee |
| Already known | You submitted this transaction twice |
| Gas price too low | Transaction rejected from mempool (legacy gas pricing) |
try {
const tx = await wallet.sendTransaction(txData);
} catch (error) {
const msg = error.message.toLowerCase();
if (msg.includes('nonce too low')) {
// Get the correct nonce and retry
const nonce = await provider.getTransactionCount(wallet.address, 'latest');
return await wallet.sendTransaction({ ...txData, nonce });
}
if (msg.includes('replacement transaction underpriced')) {
// To replace a stuck tx, new gas price must be > 110% of original
const feeData = await provider.getFeeData();
const bumpedFee = feeData.maxFeePerGas * 120n / 100n;
return await wallet.sendTransaction({ ...txData, maxFeePerGas: bumpedFee });
}
if (msg.includes('already known')) {
// Transaction is already in the mempool — wait for it
console.log('Transaction already submitted, waiting...');
return await provider.waitForTransaction(originalTxHash);
}
}
Category 4: Gas Errors
| Error | Meaning |
|---|---|
OUT_OF_GAS | Transaction ran out of gas mid-execution |
| Gas estimation fails | The transaction would revert |
INSUFFICIENT_FUNDS | Wallet does not have enough ETH for gas |
// Always estimate gas with a buffer
async function estimateWithBuffer(contract, method, args, bufferPercent = 20) {
try {
const estimate = await contract[method].estimateGas(...args);
return estimate * BigInt(100 + bufferPercent) / 100n;
} catch (error) {
if (error.code === 'CALL_EXCEPTION') {
// Estimation failed because tx would revert
throw new Error(`Transaction would revert: ${error.reason}`);
}
throw error;
}
}
// Check balance before sending
async function checkSufficientBalance(wallet, estimatedGas, gasPrice) {
const balance = await provider.getBalance(wallet.address);
const gasCost = estimatedGas * gasPrice;
if (balance < gasCost) {
throw new Error(
`Insufficient ETH for gas. Need ${ethers.formatEther(gasCost)} ETH, have ${ethers.formatEther(balance)} ETH`
);
}
}
Category 5: Rate Limiting
When you exceed a provider’s request limits, you receive 429 responses or specific error messages.
async function withRateLimit(fn, options = {}) {
const maxRetries = options.maxRetries || 5;
const baseDelay = options.baseDelay || 1000;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
const isRateLimit =
error.status === 429 ||
error.message?.includes('rate limit') ||
error.message?.includes('too many requests');
if (!isRateLimit || i === maxRetries - 1) throw error;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const delay = baseDelay * Math.pow(2, i);
console.warn(`Rate limited. Waiting ${delay}ms before retry ${i + 1}/${maxRetries}`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Prevention: Batch requests using Promise.all carefully (do not fire hundreds of requests simultaneously). Use WebSocket subscriptions instead of polling. Cache responses for data that does not change per block.
Category 6: ABI and Encoding Errors
These errors happen in your application before the request is even sent.
| Error | Meaning |
|---|---|
INVALID_ARGUMENT | Wrong parameter type passed to a function |
BUFFER_OVERRUN | ABI decoding failed: wrong ABI or corrupted data |
BAD_DATA | Response data does not match expected ABI |
try {
// Wrong: passing a string where address is expected
const balance = await token.balanceOf('not-an-address');
} catch (error) {
if (error.code === 'INVALID_ARGUMENT') {
console.error('Wrong argument type:', error.argument, error.value);
// Validate inputs before calling
}
}
Prevention: Validate inputs before making RPC calls. Use TypeScript for type safety. Use ethers.isAddress(address) before passing any address to a contract method.
function validateAddress(address) {
if (!ethers.isAddress(address)) {
throw new Error(`Invalid Ethereum address: ${address}`);
}
return address;
}
The Universal Error Handler
A complete error handler that covers all categories:
function handleRpcError(error, context = '') {
const prefix = context ? `[${context}] ` : '';
switch (error.code) {
case 'NETWORK_ERROR':
case 'TIMEOUT':
case 'SERVER_ERROR':
console.error(`${prefix}RPC connection error:`, error.message);
return { type: 'network', retryable: true, message: 'Connection to blockchain failed' };
case 'CALL_EXCEPTION':
const reason = error.reason || 'Contract reverted without reason';
console.error(`${prefix}Contract reverted: ${reason}`);
return { type: 'revert', retryable: false, message: reason };
case 'ACTION_REJECTED':
return { type: 'user_rejected', retryable: false, message: 'Transaction cancelled' };
case 'INSUFFICIENT_FUNDS':
return { type: 'funds', retryable: false, message: 'Insufficient ETH for gas' };
case 'INVALID_ARGUMENT':
console.error(`${prefix}Invalid argument:`, error.argument, error.value);
return { type: 'validation', retryable: false, message: 'Invalid input data' };
case 'UNPREDICTABLE_GAS_LIMIT':
return { type: 'revert', retryable: false, message: 'Transaction would fail' };
default:
if (error.message?.toLowerCase().includes('nonce')) {
return { type: 'nonce', retryable: true, message: 'Nonce conflict — will retry' };
}
if (error.message?.toLowerCase().includes('rate limit')) {
return { type: 'rate_limit', retryable: true, message: 'Rate limited — retrying' };
}
console.error(`${prefix}Unknown error:`, error);
return { type: 'unknown', retryable: false, message: 'Unexpected error' };
}
}
// Usage
try {
const tx = await contract.execute(params);
await tx.wait();
} catch (error) {
const handled = handleRpcError(error, 'execute');
if (handled.retryable) {
// Queue for retry
} else {
// Show handled.message to user
}
}
Debugging Transactions That Silently Failed
A transaction with status: 0 (failed) in the receipt is the silent killer. No error thrown. The transaction was mined, but the execution reverted.
const receipt = await tx.wait();
if (receipt.status === 0) {
console.error('Transaction mined but reverted. TX hash:', receipt.transactionHash);
// Replay with eth_call to get the revert reason
const tx = await provider.getTransaction(receipt.transactionHash);
const block = receipt.blockNumber;
try {
await provider.call({
to: tx.to,
from: tx.from,
data: tx.data,
value: tx.value,
}, block);
} catch (callError) {
console.error('Revert reason:', callError.reason || callError.data);
}
}
BoltRPC and Error Transparency
Error messages from your RPC provider should be clear and consistent. BoltRPC passes through standard Ethereum JSON-RPC error codes without modification. You see the actual error from the node, not a wrapped message.
Flat monthly pricing means you are not penalized for debugging. Retry logic, health checks, and eth_call simulations do not add to your bill differently from regular calls.
Start your free 2-week trial: trial.boltrpc.io
Related: RPC Failover Guide | How to Connect to Ethereum with ethers.js
FAQ
Why does my transaction receipt show status 0 but no error was thrown?
tx.wait() resolves when the transaction is mined, regardless of its execution status. Check receipt.status === 1 for success. Replay the transaction with provider.call() at the mined block number to retrieve the revert reason.
What is the difference between a reverted transaction and a failed transaction?
They are the same thing from the blockchain’s perspective. “Reverted” means the smart contract execution hit a revert or require failure. The transaction is mined (you pay gas) but all state changes are rolled back.
How do I get a human-readable revert reason?
ethers.js decodes ABI-encoded revert strings automatically and puts them in error.reason. For custom errors (Solidity error MyError(...) syntax), use contract.interface.parseError(error.data). If neither is available, replay the call manually as shown above.
Should I always catch errors with try/catch?
For all user-facing operations, yes. At minimum, distinguish between errors that are the user’s fault (insufficient balance, wrong input) and errors that are infrastructure issues (network error, provider down). Show different messages to the user for each.
What causes “nonce too low”?
A transaction with the same or lower nonce was already mined. This happens when: (1) you send the same transaction twice, (2) your local nonce cache is stale, (3) another process sent a transaction from the same wallet. Always fetch nonce with provider.getTransactionCount(address, 'latest') rather than tracking it manually.