← Back to Writing
Web3 Ethereum UX

Surviving Gas Wars: The Ethereum Transaction Lifecycle in Your UI

August 25, 2022 · 7 min read

"Send the transaction, show a spinner" is a lie you tell users right up until the network is busy. During a high-demand mint, gas spikes; a transaction submitted at yesterday's fee sits in the mempool for an hour, or silently gets dropped. Users panic, resubmit, double up their intent, and lose money to fees on transactions that never landed. The spinner knows none of this, because a spinner models "we are waiting," and what is actually happening is a small state machine with several genuinely different ways to end, some of which cost the user money.

flowchart LR B["Build"]:::ghost --> S["Sign"] --> P["Pending (mempool)"] P --> M["Mined: success"]:::accent P --> F["Mined: reverted"]:::gold P --> D["Dropped / replaced"]:::gold

A transaction is a state machine

Model the real states in the UI: built, signed, submitted, pending in the mempool, then mined, where mined splits into success and reverted, plus the side exits of dropped or replaced. "Reverted" is the one people forget, and it is the cruelest: the transaction did mine, it cost the user gas, and it failed anyway. Telling someone "still pending" when the truth is "it failed and you paid for it" is not a small copy error, it is a trust error. Each state deserves its own honest message and its own set of available actions.

Gas is not a constant

Static gas is what breaks first under load. With EIP-1559 you estimate a base fee, which the network sets and which moves block to block, plus a priority tip, which is your bid for faster inclusion, and you derive both from live conditions rather than a hardcoded number that was fine last Tuesday. Then you keep watching the mempool, because the entire problem is that conditions change after you submit. A fee that was generous at submission can be too low ten blocks later, and the user is left stranded with no idea why their transaction stopped moving.

The two escape hatches

When a transaction stalls, users need two ways out, and both hinge on reusing the same nonce.

flowchart TD P["Pending too long"] --> Q{"User choice"} Q -->|speed up| RBF["Resubmit, same nonce, higher tip"] Q -->|cancel| Cx["0-value self-send, same nonce"] Q -->|wait| P RBF --> M["Mined / replaced"]:::accent Cx --> M

Speed-up is a replace-by-fee: you resubmit the same nonce with a tip high enough that the network prefers the replacement, and there is a minimum bump below which it is rejected as a duplicate, which you have to respect or the "speed up" button silently does nothing. Cancel is a zero-value self-send on that same nonce, racing to occupy the slot before the original transaction lands. Both are nonce surgery, which is exactly where this gets dangerous.

The traps

Nonce management is unforgiving. Nonces are sequential per account, so a single stuck transaction at nonce N blocks every later transaction behind it; the account is wedged until that one slot resolves, and a UI that does not understand this leaves users completely stuck with no explanation and a growing queue of pending actions that will never confirm. RPC providers add their own noise: they lag, they cache, and they occasionally contradict each other about whether a transaction is pending, mined, or gone. So you confirm against the chain rather than trusting a single provider's word, you wait for a sensible confirmation depth because a one-block "success" can still be reorganized away, and where it matters you cross-check more than one provider before you tell a user their money has moved.

What the user should see at each state

The states only help if the interface speaks them plainly, because a user staring at an opaque "processing" message invents their own story, and the story is usually "this is broken, let me try again," which is the single worst thing they can do. Pending should say pending, show the fee the transaction is currently bidding, and offer the two actions that matter, speed up and cancel, instead of a bare spinner. A speed-up or a cancel that is itself in flight is a new pending state and should read that way, or the user will mash the button and create a third competing transaction. Success links to the confirmation and shows the amount that actually moved, not the amount they intended to move, because gas means those differ. Reverted is the state to get exactly right: say clearly that it failed, that it still cost gas, and, where you can decode it, why. Dropped offers a clean retry rather than leaving a ghost row that confuses the next session. Every message answers the only two questions the user actually has, where is my money and what can I do about it.

The nonce queue is a UX problem, not just a backend one

Because nonces are strictly sequential per account, a single stuck transaction does not just delay itself, it freezes everything queued behind it, and that reality has to surface in the interface or it will baffle people. If a user submits three actions and the first stalls on a low fee, the second and third cannot confirm no matter how high their own fees are, and an app that shows three independent spinners is actively lying about what is happening. The honest design treats the queue as a queue: it shows that later actions are waiting on an earlier one, it points the user at the blocker, and it lets them speed up or cancel the transaction that is actually holding the line. Hide this and you generate a steady stream of support tickets from users convinced your app ate their money, when in fact one cheap transaction is wedged at the front.

Testing this without a real gas war

You cannot wait for the next congestion spike to learn whether your handling works, so you manufacture the conditions deliberately. A forked mainnet lets you replay real network state locally, and from there you submit a transaction with an intentionally low tip and watch it sit, then exercise the speed-up and cancel paths against it for real. You fuzz the ugly cases on purpose: a nonce gap, a transaction that reverts, a provider that reports stale or contradictory state, two speed-ups fired in quick succession, a reorg that un-mines a confirmed transaction. These situations are rare on a calm day and routine during a busy mint, which is precisely when you cannot afford to be discovering them live, so you make them ordinary in a test harness long before a user hits them with real funds on the line.

Tell the truth

The thread running through all of it is that your job is to represent reality, not to soothe. Show the user which state their transaction is actually in, what it has cost so far, and what they can do about it. "We do not know yet, here is how to check" is a more trustworthy message than a spinner that implies progress it cannot see. In an environment where users are watching real money sit in limbo, honest uncertainty beats confident fiction every single time, and it is the difference between an app people trust with funds and one they quietly abandon.

Enjoyed this? Let me know

0claps