web3icons

Pending, confirmed, failed: transaction state design

The full transaction lifecycle from signature request to confirmed, failed or replaced, what to show at each step, explorer links and how to recover.

5 min read

A blockchain transaction passes through several states between the click and the result, and some of them happen outside your app: in the wallet, in the network's queue of pending transactions, or in a block. If the interface collapses all of that into one spinner, people can't tell whether to wait, retry or worry. Many duplicate transactions and support tickets start there.

This guide lists the states, what you can detect for each, what to show, and how to recover when things go wrong.

The lifecycle at a glance

State How you know What to show What the person can do
Awaiting signature Request sent to the wallet "Confirm in your wallet" Cancel, reopen the wallet
Rejected Wallet returns error 4001 Back to the form, neutral note Try again
Pending You have a transaction hash Submitted, expected time, explorer link Leave the page, view on explorer
Confirmed Receipt with status success Result and updated balances Next action
Failed Receipt with status reverted What failed, fee was charged Fix inputs and retry
Replaced A different transaction used the same nonce Follow the replacement Depends on whether it was sped up or cancelled
Dropped Never included, no longer known to the network Not processed, nothing charged Submit again

Modeling these explicitly in code makes it hard to forget one:

type Hash = `0x${string}`

type TxState =
  | { status: 'idle' }
  | { status: 'awaiting-signature' }
  | { status: 'rejected' }
  | { status: 'pending'; hash: Hash; submittedAt: number }
  | { status: 'confirmed'; hash: Hash; blockNumber: bigint }
  | { status: 'failed'; hash: Hash; reason?: string }
  | { status: 'replaced'; hash: Hash; replacement: Hash; cancelled: boolean }
  | { status: 'dropped'; hash: Hash }

Awaiting signature

The wallet popup is open, or should be. This is where people get stuck most often: the popup opened behind the browser window, the extension is locked, or the mobile wallet app didn't come to the front.

Show a clear waiting state in your interface, not just a disabled button:

Confirm the swap in your wallet.

Don't see it? Open your wallet extension, or check your phone if you connected with a mobile wallet.

Keep a Cancel action that returns the interface to the form. Cancelling in your app can't close the wallet's popup, but it lets the person start over without reloading.

If the wallet returns a user-rejected error, go straight back to the form with inputs intact and a short neutral line such as "Request cancelled in your wallet." Don't show it in red. Declining is a choice, not a failure.

Pending

Once the wallet returns a transaction hash, the transaction has been broadcast. This is the right moment to tell people they're free to leave:

Swap submitted. Usually confirms in under a minute.

You can close this page; the swap will still go through.

View on explorer

Link the hash to the block explorer for the chain the transaction is on, not a hard-coded mainnet explorer. A short hash such as 0x8f3a…91c2 is enough as link text, with a clear label next to it.

const explorers: Record<number, string> = {
  1: 'https://etherscan.io',
  8453: 'https://basescan.org',
  42161: 'https://arbiscan.io',
}

function txUrl(chainId: number, hash: string): string | undefined {
  const base = explorers[chainId]
  return base ? `${base}/tx/${hash}` : undefined
}

Persist pending transactions, keyed by account and chain, in local storage. If the person reloads or comes back later, check each stored hash for a receipt and resume the right state. Without this, a reload during a pending swap shows the old balance and invites a second, duplicate swap.

Give an expectation of time that matches the network, and change the message if the wait runs long: "Taking longer than usual. Network fees may have risen since you submitted." On many networks you can't promise a time, so describe the usual case rather than a countdown.

Confirmed

When the receipt arrives with a success status, update everything that changed: balances, positions, the activity feed. A success message with a stale balance next to it makes people doubt the success.

How many confirmations to wait for depends on the network and the stakes. For most interface updates, the first inclusion in a block is enough to show success. For large or irreversible actions, such as crediting a deposit, wait for more confirmations or for finality, and say so: "Received. Available after 12 confirmations."

Failed

A receipt with a reverted status means the transaction was included in a block but its execution failed. Two things matter to the person: why it failed, and the fact that the network fee was still charged, because the network did the work of processing it. Be honest about the second point.

Swap failed. The price moved more than your 0.5% limit before the swap was processed.

The network fee (≈ $0.38) was still charged.

Try again with a higher limit

Decode the revert reason when you can, map known reasons to plain explanations, and keep the raw reason available behind "Details" for support. Writing error messages for dApps has a full set of before-and-after examples.

Replaced and dropped

Wallets let people "speed up" or "cancel" a pending transaction by sending a new one with the same nonce and a higher fee. Only one of them can be included. If you only watch the original hash, it will never confirm, and your interface will spin forever.

Watch for replacement. Client libraries such as viem and ethers can detect it and report whether the new transaction did the same thing at a higher fee (a speed-up) or something else, typically a zero-value transfer to self (a cancellation). Then:

  • Sped up: switch to tracking the new hash and continue as pending. Mention it briefly, or not at all.
  • Cancelled: show that the action didn't happen and nothing was swapped, and that a small fee was paid for the cancellation.

A dropped transaction was never included and the network no longer knows about it, often because its fee was too low for too long. Nothing was charged. Offer to submit it again with a current fee estimate, shown the way showing gas fees people understand describes.

Multi-step flows

Many actions need two transactions, typically an approval and then the action. Show the steps as a numbered sequence, "Step 1 of 2: Approve USDC", "Step 2 of 2: Swap", with the state of each. If the approval succeeds but the person abandons the second step, the next visit should start at step 2, not ask for approval again.

Every state change described here is also an announcement for screen reader users. Announcing transaction status to screen readers covers live regions that report progress without flooding people with updates.

More in Web3 UX

All guides