Every Ethereum account has a nonce — a counter incremented with each confirmed transaction. The Base sequencer rejects any transaction whose nonce doesn’t match what it expects for your account. For agents submitting multiple transactions, incorrect nonce handling is the most common cause of stuck or cascading failures.
The Two Nonces You Need to Understand
| Nonce type | eth_getTransactionCount tag | Reflects |
|---|
| Confirmed nonce | "latest" | Transactions included in finalized blocks |
| Pending nonce | "pending" | Confirmed + all transactions currently in the mempool |
Always use "pending" when building a new transaction. Using "latest" when there are unconfirmed transactions in the mempool will assign a nonce that’s already claimed, causing a nonce too low rejection.
{
"jsonrpc": "2.0",
"method": "eth_getTransactionCount",
"params": ["0xYourAddress", "pending"],
"id": 1
}
Single-Thread Agent (Sequential Submissions)
For agents that submit one transaction at a time and wait for confirmation before the next, the simplest approach is always fetching the pending nonce fresh:
async function sendNext(txParams: TxParams) {
const nonce = await client.getTransactionCount({
address: agentAddress,
blockTag: 'pending',
});
return walletClient.sendTransaction({ ...txParams, nonce });
}
Timing on Base: With Flashblocks at 200ms and 2-second block times, a transaction typically confirms within 2–4 seconds under normal conditions. A sequential agent can safely fetch a fresh nonce before each submission.
Multi-Thread / Concurrent Agent (Parallel Submissions)
When an agent needs to submit multiple transactions without waiting for each to confirm (e.g., multiple position adjustments), fetch-nonce-per-call fails because all calls will return the same pending nonce.
Use a local nonce manager that increments atomically:
class NonceManager {
private nonce: bigint | null = null;
private lock = false;
private queue: Array<() => void> = [];
async acquire(address: `0x${string}`): Promise<bigint> {
// Initialize from chain if not set
if (this.nonce === null) {
this.nonce = BigInt(
await client.getTransactionCount({ address, blockTag: 'pending' })
);
}
return this.nonce++;
}
// Call after a tx is confirmed to sync if drift is detected
async sync(address: `0x${string}`) {
const chainPending = BigInt(
await client.getTransactionCount({ address, blockTag: 'pending' })
);
// Only advance forward, never backward
if (chainPending > (this.nonce ?? 0n)) {
this.nonce = chainPending;
}
}
}
const nonceManager = new NonceManager();
// Submit multiple transactions concurrently
const [hash1, hash2, hash3] = await Promise.all([
walletClient.sendTransaction({ ...tx1, nonce: Number(await nonceManager.acquire(address)) }),
walletClient.sendTransaction({ ...tx2, nonce: Number(await nonceManager.acquire(address)) }),
walletClient.sendTransaction({ ...tx3, nonce: Number(await nonceManager.acquire(address)) }),
]);
Never decrement a local nonce counter. If a transaction is rejected, the nonce it was assigned may or may not need to be reused depending on the error. See the recovery section below.
Nonce Gaps and Queue Stalls
A nonce gap occurs when a transaction with nonce N is missing (failed, never submitted, or dropped), while transactions with nonces N+1, N+2, … are waiting in the mempool. The sequencer will not process N+1 until N is resolved.
Detecting a gap:
async function detectNonceGap(address: `0x${string}`) {
const confirmed = await client.getTransactionCount({ address, blockTag: 'latest' });
const pending = await client.getTransactionCount({ address, blockTag: 'pending' });
// If pending > confirmed, check if there's a tx for each nonce in between
// In practice: if a tx hasn't confirmed after N blocks, treat it as a gap
return { confirmed, pending, gap: pending > confirmed };
}
Resolving a gap: Submit a new transaction at the missing nonce. This can be a zero-value self-transfer or the original transaction with adjusted fees:
async function fillNonceGap(
missingNonce: number,
address: `0x${string}`
) {
const { maxFeePerGas, maxPriorityFeePerGas } = await computeOptimalFees('urgent', 0);
return walletClient.sendTransaction({
to: address, // self-transfer to fill the gap
value: 0n,
nonce: missingNonce,
maxFeePerGas: (maxFeePerGas * 150n) / 100n, // high fee to land quickly
maxPriorityFeePerGas: (maxPriorityFeePerGas * 150n) / 100n,
gas: 21000n,
});
}
Once the gap is filled, all queued transactions (N+1, N+2, …) will process in sequence.
Error Recovery by Nonce Error Type
| Error | Meaning | Action |
|---|
nonce too low | The nonce you used is already confirmed or claimed by a higher-fee tx | Fetch fresh pending nonce, rebuild and resubmit |
nonce too high | Your nonce is too far ahead (gap in sequence) | Submit a tx at the missing nonce first, then retry |
replacement transaction underpriced | A tx with this nonce exists and your fees aren’t 10% higher | Increase fees by ≥10%, resubmit with same nonce |
| Transaction stuck (no receipt after 10+ blocks) | Tx may be in mempool but underprioritized | Replace with same nonce + ≥15% higher fees |
After a nonce too low rejection
async function handleNonceTooLow(address: `0x${string}`, manager: NonceManager) {
// Sync local nonce manager to chain state
await manager.sync(address);
// Rebuild and resubmit with the new nonce
const newNonce = Number(await manager.acquire(address));
return walletClient.sendTransaction({ ...originalTxParams, nonce: newNonce });
}
Nonce Management with Account Abstraction
If your agent uses an ERC-4337 smart account, nonce management works differently:
- The smart account has its own nonce tracked by the EntryPoint contract, not
eth_getTransactionCount
- UserOps are submitted via
eth_sendUserOperation to a bundler, which handles sequencing
- Multiple UserOps can share a 2D nonce (
key + sequence) for parallel submission lanes
For concurrent UserOp submission, use different nonce keys (upper 192 bits) to create independent queues:
Lane 0: nonce = (0 << 64) | sequenceInLane0
Lane 1: nonce = (1 << 64) | sequenceInLane1
See Account Abstraction for more on ERC-4337 on Base.
Monitoring Nonce Health
For long-running agents, periodically check that the local nonce is in sync with chain state:
setInterval(async () => {
const chainPending = BigInt(
await client.getTransactionCount({ address: agentAddress, blockTag: 'pending' })
);
const localNonce = nonceManager.peek(); // current value without incrementing
if (chainPending > localNonce) {
console.warn(`Nonce drift detected: chain=${chainPending}, local=${localNonce}`);
await nonceManager.sync(agentAddress);
}
}, 30_000); // every 30 seconds