web3icons

Writing error messages for dApps

How to turn raw RPC and wallet errors into messages that say what happened, why it happened and what to do next, with before and after examples.

5 min read

Errors in a dApp come from several layers at once: the wallet, the JSON-RPC node, the smart contract and your own code. Each layer has its own vocabulary, and most of it was written for developers. When those strings reach the interface unchanged, people see "execution reverted" or "-32603 Internal JSON-RPC error" and have no way to know whether they lost money, did something wrong, or should simply try again.

A good error message answers three questions: what happened, why, and what to do next. This guide shows where dApp errors come from, how to map them, and what the result should read like.

Know where the error came from

You can only explain an error if you know which layer produced it. These are the main sources and some of the raw messages you'll see from each.

Source Typical raw message or code What it usually means
Wallet (EIP-1193) 4001 User Rejected Request The person declined in their wallet
Wallet 4100 Unauthorized The site isn't connected to this account
Wallet 4900 Disconnected, 4901 Chain Disconnected The wallet lost its connection
Wallet (common, not universal) 4902 The wallet doesn't know this network
JSON-RPC -32603 Internal error A generic wrapper; look inside for the real cause
Node insufficient funds for gas * price + value Not enough native token for amount plus fee
Node nonce too low A transaction with this nonce already went through
Node replacement transaction underpriced A pending transaction exists with the same nonce
Contract execution reverted plus a reason or custom error The contract refused the action

The exact wording of node messages depends on the node software and RPC provider, so match on codes and stable fragments rather than whole strings, and test against the providers you actually use. Client libraries often wrap errors several levels deep, with the useful code on an inner cause:

function findErrorCode(error: unknown): number | undefined {
  let current: unknown = error
  for (let depth = 0; current && depth < 5; depth++) {
    const candidate = current as { code?: unknown; cause?: unknown }
    if (typeof candidate.code === 'number') return candidate.code
    current = candidate.cause
  }
  return undefined
}

User rejection is not an error

The most common "error" in any dApp is a person clicking Reject in their wallet. Raw messages for it vary by wallet, from "User rejected the request." to "MetaMask Tx Signature: User denied transaction signature."

Treat it as a normal outcome. Return to the form, keep everything they entered, and show a short neutral line, not a red banner:

Request cancelled in your wallet. Nothing was sent.

Don't write "You rejected the transaction". It reads like blame, and the person may have rejected it on purpose because something looked wrong, which is exactly what they should do.

Before and after

Rewriting raw errors is mostly a matter of following the three questions. Some examples:

Insufficient gas token

  • Before: insufficient funds for gas * price + value
  • After: "You don't have enough ETH on Base to pay the network fee. Add a small amount of ETH on Base, or lower the amount you're sending."

Slippage revert

  • Before: execution reverted: Too little received
  • After: "The swap didn't go through because the price moved more than your 0.5% limit. The network fee was still charged. Try again, or raise your price limit."

Wrong network

  • Before: Unsupported chain id 1
  • After: "This app runs on Base. Switch your wallet to Base to continue." With a "Switch to Base" button.

Pending transaction conflict

  • Before: replacement transaction underpriced
  • After: "You already have a transaction waiting in your wallet. Wait for it to finish, or speed it up or cancel it from your wallet, then try again."

Node or provider failure

  • Before: a timeout, or -32603 Internal JSON-RPC error with nothing more specific inside
  • After: "We couldn't reach the network just now. Your funds haven't moved. Try again in a moment."

Unknown contract error

  • Before: execution reverted: 0x7939f424
  • After: "The contract rejected this transaction. No tokens were moved, but the network fee was charged. Details are below if you need to contact support."

Notice what the good versions share. They say whether money moved, because that's the first thing people worry about. They name the specific network and token. They end with an action the person can take.

Decode contract errors

Modern contracts often revert with custom errors, which arrive as a 4-byte selector followed by encoded arguments. With the contract's ABI, your client library can decode them into a name such as InsufficientOutputAmount and its parameters. Keep a map from known error names to messages, and fall back to a generic message plus details for anything unmapped.

const contractMessages: Record<string, string> = {
  InsufficientOutputAmount: 'The price moved more than your limit before the swap was processed.',
  Expired: 'This quote expired before it was submitted. Get a new quote and try again.',
  TransferFailed: "The token transfer failed. Check that you still hold enough of the token you're selling.",
}

The names above are illustrations; use the error names from your own contracts' ABIs.

Where and how to show errors

Next to the action that failed. An error under the Swap button is easier to connect to its cause than a toast in the corner that disappears after four seconds.

Keep the inputs. Never clear the form after an error. The person should be able to fix one field and retry.

Offer details, not a stack trace. A collapsed "Details" section with the raw message, error code, transaction hash if there is one, and a copy button gives support what it needs without putting it in the main message.

Mind the tone. Avoid "Oops", "Whoops" and "Something went wrong" on its own. They don't help, and in a financial context they can sound flippant. Plain and specific is calmer.

Announce it. Errors that appear without a page change need to be announced to screen reader users, usually with a role="alert" region for failures. Announcing transaction status to screen readers explains when to use alert versus status.

Errors that should never happen

Some errors are design bugs. If people often see "insufficient funds for gas", check the native balance before they start. If they hit "wrong network", ask to switch on the button, as described in network switching without confusion. If transactions often fail on slippage, look at your default limit and quote freshness. Track which errors appear most, and treat the top few as issues to remove from the flow, not just text to improve. The state model in transaction state design helps make sure every outcome has a message at all.

More in Web3 UX

All guides