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 errorwith 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.