Solana

Solana Beta is live. Try BoltRPC Solana endpoints free - start your trial now.

How to Handle RPC Errors in Web3: A Production Guide

Every RPC error your dApp will encounter and exactly how to handle each one. Covers ethers.js v6 error types, retry logic, transaction failure debugging and production patterns.

BoltRPC
BoltRPC Team
9 min read
How to Handle RPC Errors in Web3: A Production Guide

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.

CodeMeaning
NETWORK_ERRORCould not connect to the RPC endpoint
TIMEOUTRequest took too long, connection dropped
SERVER_ERRORProvider 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.

CodeMeaning
CALL_EXCEPTIONContract call reverted
ACTION_REJECTEDUser rejected the transaction (wallet)
UNPREDICTABLE_GAS_LIMITGas 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.

ErrorMeaning
Nonce too lowA transaction with this nonce was already mined
Nonce too highGap in nonce sequence (pending tx missing)
Replacement transaction underpricedTrying to replace a pending tx with too low a fee
Already knownYou submitted this transaction twice
Gas price too lowTransaction 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

ErrorMeaning
OUT_OF_GASTransaction ran out of gas mid-execution
Gas estimation failsThe transaction would revert
INSUFFICIENT_FUNDSWallet 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.

ErrorMeaning
INVALID_ARGUMENTWrong parameter type passed to a function
BUFFER_OVERRUNABI decoding failed: wrong ABI or corrupted data
BAD_DATAResponse 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.

Frequently asked questions

Ready to build with high-performance RPC?

Start your free trial today. No credit card required. Access 20+ networks instantly.

Disclaimer: The content in this article is for informational purposes only and does not constitute financial, legal, or technical advice. Code examples and configurations are provided as-is. Always verify information with official documentation and test thoroughly in your own environment before deploying to production.

Continue reading