Nonce Management for High-Throughput Senders
Here's a failure mode that takes down backends: a service sends transactions from a single hot wallet, it relies on the node to tell it the next nonce, and under load two transactions grab the same nonce — or one underpriced transaction gets stuck and every transaction behind it queues forever. If you're building a relayer, a market maker, a bridge, or any service that sends from one account at volume, nonce management is the thing that will bite you. Let's get it right.
What a nonce actually is
Every Ethereum account has a nonce: a counter of how many transactions it has sent, starting at 0. Each transaction from an account must carry the next sequential nonce, and the network includes them in strict nonce order — nonce 5 can't be mined before nonce 4, ever.
This ordering is a feature (it prevents replay and gives you deterministic sequencing), but it's also the trap: the account is a single serialized lane. One transaction stuck at the front blocks everything behind it, no matter how high those later transactions bid.
The two nonce views a node gives you
When you ask a node for an account's nonce via eth_getTransactionCount, the answer depends on the block tag:
| Call | Returns |
|---|---|
eth_getTransactionCount(addr, "latest") |
Count of transactions mined so far (confirmed nonce) |
eth_getTransactionCount(addr, "pending") |
Confirmed plus what's sitting in this node's mempool |
For low-volume sending, "pending" is the convenient answer: "give me the next nonce including my in-flight transactions." And for a single sender doing one transaction at a time, it works fine. The problem is what happens at scale.
Why "pending" breaks under load
Three things make eth_getTransactionCount(addr, "pending") unreliable as your source of truth once you're sending fast:
- It's a network round-trip. If you fire 50 transactions in the time one
getTransactionCountcall returns, several will read the same "pending" value and collide on a nonce. Only one of each nonce survives; the rest are rejected asnonce too lowor silently replace each other. - The mempool view is per-node and eventually-consistent. "Pending" reflects that specific node's mempool. Behind a load balancer, or right after a transaction is dropped, different nodes disagree about what's pending — so the nonce you get back flaps.
- Dropped transactions create gaps. If an in-flight transaction gets evicted (underpriced, timed out), the node's "pending" count can move backward, and you can hand out a nonce you already used or skip one.
The fix is the same pattern every high-throughput sender converges on: stop asking the node for each nonce. Track it yourself.
Manage the nonce locally
Keep an in-memory counter per sending account. Seed it once from the chain, then increment it yourself for every transaction you dispatch — the node becomes a fallback, not the source of truth.
// Minimal local nonce manager (single account)
let nextNonce = null;
async function init(client, address) {
// Seed from confirmed state — "latest", not "pending"
nextNonce = await client.getTransactionCount({ address, blockTag: "latest" });
}
function takeNonce() {
const n = nextNonce;
nextNonce += 1; // reserve it immediately, before the send returns
return n;
}
The critical detail: increment the counter the instant you allocate a nonce, synchronously, before the async send resolves. That's what prevents two concurrent sends from taking the same number. Serialize allocation (a lock/queue around takeNonce) so it's atomic.
Seed from "latest" (confirmed), not "pending" — you're tracking your own in-flight transactions in memory, so you don't want the node's fuzzy pending view double-counting them.
Handling the things that go wrong
A local counter is necessary but not sufficient — you also need to handle the failure cases, because at volume they will happen:
A send fails outright (RPC rejects it). The nonce you reserved was never broadcast. You must return it to the pool (or reset your counter to it), or you'll leave a permanent gap that stalls everything after it.
const nonce = takeNonce();
try {
await client.sendRawTransaction({ ...tx, nonce });
} catch (e) {
nextNonce = Math.min(nextNonce, nonce); // give the nonce back
throw e;
}
A transaction gets stuck (underpriced). It occupies its nonce in the mempool and blocks all higher nonces. You can't skip it — you have to replace it: resend the same nonce with a higher fee (Ethereum needs a ~10%+ bump to accept a replacement). Either speed it up (same transaction, higher tip) or cancel it (a 0-value self-send at that nonce). This is the same replace-by-nonce mechanic covered in the mempool explainer — at scale you want it automated: if a nonce hasn't confirmed within N blocks, auto-bump it.
Your counter drifts from reality. After a crash, restart, or a batch of failures, reconcile: compare your local nextNonce against getTransactionCount("latest") and ("pending"). If confirmed has caught up to or passed your counter, resync. A periodic reconciliation loop keeps a long-running sender honest.
Going wider: parallel nonce lanes
A single account is one serialized lane — there's a ceiling on how fast it can drain, because everything is strictly ordered. When you need more throughput than one account can push, the standard move is multiple sending accounts: shard your outbound transactions across N hot wallets, each with its own independent nonce sequence. Ten accounts = ten parallel lanes, and a stuck transaction on one lane doesn't block the other nine. Relayers and market makers do exactly this. (It also contains blast radius: one wedged nonce degrades 1/N of your throughput, not all of it.)
Confirm, don't assume
Allocating and broadcasting a nonce isn't the finish line — the transaction still has to land. Track each sent nonce until you see it confirmed in a receipt (status: 0x1), and remember a transaction can be reorged back out after appearing mined. Your nonce manager's state should follow confirmed reality, not "I sent it."
And on fast chains, all of this matters more. On a ~400ms Sei or a ~1s L2, nonces advance quickly and the window for collisions is tighter — local management stops being an optimization and becomes a requirement. Pair it with accurate gas estimation so your replacements clear the fee bump on the first try.
The short version
Nonces are a strict per-account sequence, so one account is a single serialized lane and one stuck transaction blocks everything behind it. eth_getTransactionCount("pending") is fine for casual sending but collides and flaps under load — so high-throughput senders track the nonce locally, incrementing a counter the instant they allocate (seeded from "latest"), return nonces on send failure, replace stuck transactions by re-sending the same nonce with a higher fee, reconcile against the chain after crashes, and shard across multiple accounts for parallel lanes. Confirm every nonce against a receipt, and tighten all of it on fast chains.
Reliable high-volume sending needs an endpoint that serves eth_getTransactionCount, eth_sendRawTransaction, and receipts consistently under load. A flat-rate Ethereum RPC endpoint gives you that across dozens of chains under one key — no per-request metering to punish a busy relayer. Grab a free key and point your sender at:
https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY
Related posts
- eth_getBlockReceipts: Every Receipt in a Block, One Call
Fetching every receipt in a block one transaction at a time is the N+1 problem in disguise — hundreds of round-trips per block. eth_getBlockReceipts returns all of them in a single call. Here's how it works, when to use it over JSON-RPC batching, and how to fall back when a node doesn't support it.
- Did It Actually Succeed? Reading eth_getTransactionReceipt
A transaction being mined does not mean it worked — a reverted transaction still lands in a block and still costs you gas. The receipt is where the truth lives: the status flag, the event logs, the real gas paid, and how many confirmations you should wait. Here's how to read one.
- Estimating Gas Right: eth_estimateGas, EIP-1559, and Buffers
Half of failed transactions are gas mistakes: an 'out of gas' revert or a fee too low to ever get mined. Here's how eth_estimateGas actually works, how EIP-1559 fees are built, why you buffer the gas limit, and how to put it all together without overpaying.