Skip to main content

Ethereum nonce management for SDK developers

The Ethereum transaction nonce is a sequential counter per account that enforces strict transaction ordering and prevents replay attacks — and it is the single most common source of stuck transactions, race conditions, and silent failures in production systems that send transactions programmatically. For SDK builders using ethers.js, mastering nonce mechanics is non-negotiable: the built-in NonceManager handles simple cases, but any system sending concurrent transactions from the same wallet needs a custom nonce management layer backed by atomic operations. This report covers the full depth of the problem, from Yellow Paper fundamentals through production-grade queue architectures.

How the EVM enforces nonces at the protocol level

Every Ethereum account (EOA or contract) stores four fields in the world state trie: nonce, balance, storageRoot, and codeHash. The nonce for an EOA represents the total number of transactions sent; for a contract account, it counts contract creations via CREATE. In the Yellow Paper, the transaction nonce is denoted Tₙ and the account state nonce is σ[a]ₙ. The nonce is a uint64 value and occupies the first field in legacy (Type 0) RLP-encoded transactions: (nonce, gasPrice, gasLimit, to, value, data). For EIP-1559 (Type 2) transactions, it sits second after chainId: 0x02 || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, ...signature]). The EVM’s validation rule is strict equality: the transaction nonce must exactly match the sender’s current account nonce (Tₙ = σ[S(T)]ₙ). Not less, not greater — exact match. Upon execution, the very first operation is incrementing the sender’s nonce by 1, before any value transfer or contract interaction. This increment persists even if the transaction reverts — gas is consumed and the nonce advances regardless of execution outcome. This strict sequencing creates two critical properties. First, same-chain replay protection: once nonce N executes, the account nonce becomes N+1, and any replay of the same signed transaction fails validation. Second, total ordering of transactions per account: nonce N cannot be included before nonce N-1, making it impossible to reorder a sender’s transactions within the protocol. Cross-chain replay protection is handled separately by EIP-155, which embeds the chain ID into the transaction signing hash, making signatures chain-specific. One often-overlooked consequence: the CREATE opcode uses the sender’s nonce to compute the new contract’s address via keccak256(rlp([sender, nonce]))[12:]. This means the sender’s nonce at deployment time deterministically controls where a contract deploys.

Pending vs confirmed: how nodes actually track nonces

The distinction between confirmed and pending nonces is where most production bugs originate. The confirmed nonce (eth_getTransactionCount(address, "latest")) reflects the on-chain state at the latest mined block — the count of transactions already included in the blockchain. The pending nonce (eth_getTransactionCount(address, "pending")) attempts to account for transactions sitting in the node’s mempool, returning confirmed nonce + the count of contiguous pending transactions from that account. The critical word is “attempts.” There is no single global mempool. Each node maintains its own independent transaction pool with different contents. When using load-balanced RPC providers like Alchemy or Infura, consecutive requests may hit different backend nodes with entirely different mempool views. Node A may have seen your pending transaction; Node B may not. This inconsistency is the root cause of most production nonce issues. Different node implementations also behave differently:
  • Geth splits its txpool into two sub-pools: pending (executable, with contiguous nonces from confirmed state) and queued (future nonces with gaps). Default limits are 5,120 pending slots globally, 16 per account, and 1,024 queued slots with a 3-hour expiry. Replacement transactions require at least a 10% gas price increase (txpool.pricebump = 10).
  • Erigon uses three sub-pools — pending, baseFee (valid nonce but gas too low for current base fee), and queued — with a larger default pool size of 30,000.
  • Infura has blocked txpool_* methods since 2018, so you cannot inspect the mempool directly. Some providers effectively return the latest count even when pending is requested.
When a transaction arrives at a node, validation proceeds in two stages. If the nonce is below the confirmed nonce, the transaction is immediately rejected with “nonce too low.” If the nonce is above the next expected pending nonce (creating a gap), the transaction enters the queued pool — it won’t be propagated to peers, won’t be included in blocks, and will expire after a timeout. Only when a gap-filling transaction arrives can queued transactions be promoted to the executable pending pool.

The five nonce failure modes every SDK must handle

Nonce gaps block everything downstream

A nonce gap occurs when a transaction is dropped from the mempool (due to low gas during congestion, pool eviction, or node restarts) while subsequent nonces have already been submitted. If your account’s confirmed nonce is 5 and you’ve submitted nonces 5, 6, 7, but nonce 5 gets evicted, nonces 6 and 7 become permanently stuck in the queued pool until nonce 5 is resubmitted. In Geth, those queued transactions expire after 3 hours by default.

Race conditions in concurrent transaction submission

This is the core challenge for SDK development. When multiple threads call eth_getTransactionCount("pending") simultaneously, they all receive the same nonce. All but one submission will fail with “nonce has already been used.” Even serial calls can race: due to the eventually consistent nature of Ethereum nodes, two sequential calls to getTransactionCount("pending") may return the same value if a transaction was submitted between them but hasn’t propagated. As ethers.js author ricmoo noted: “some backends do not honour the ‘pending’ block tag at all.”

Transaction replacement requires precise gas bumping

To replace a pending transaction, you submit a new transaction with the same nonce but higher gas. Geth requires at least 10% increase in both maxFeePerGas and maxPriorityFeePerGas. A subtle edge case exists: if the base fee drops significantly between the original and replacement, recalculating maxFeePerGas = 2 * baseFee + tip might produce a lower fee cap than the original despite a higher tip, causing rejection as “replacement transaction underpriced.” Always ensure replacement values are ≥110% of originals regardless of current base fee:
const bumpedMaxFee = (originalTx.maxFeePerGas * 112n) / 100n;
const bumpedPriority = (originalTx.maxPriorityFeePerGas * 112n) / 100n;
const maxFeePerGas = bumpedMaxFee > feeData.maxFeePerGas 
  ? bumpedMaxFee : feeData.maxFeePerGas;

Load-balanced RPCs create phantom nonce conflicts

When your SDK uses a provider behind a load balancer, transaction submission may hit Node A while a subsequent nonce query hits Node B, which hasn’t received the pending transaction yet. This produces stale nonce values that cause collisions. Broadcasting the same signed raw transaction to multiple nodes is safe (duplicates return “already known”), but reading nonce state from inconsistent endpoints is not.

Mempool eviction silently breaks nonce sequences

Geth’s per-account pending limit is just 16 slots by default. Submitting more than 16 concurrent transactions from one account causes later ones to be dropped. The evicted transactions leave no record — your system thinks they’re pending, but the network has forgotten them. This silently creates nonce gaps that block all subsequent transactions.

ethers.js nonce handling across v5 and v6

Automatic nonce population differs by version

When you call signer.sendTransaction() without specifying a nonce, ethers.js auto-fills it during the internal populateTransaction() step. The default behavior changed significantly between versions:
  • ethers.js v5 populates the nonce via signer.getTransactionCount("latest") — using only confirmed transactions, ignoring the mempool entirely
  • ethers.js v6 populates via signer.getNonce("pending") — attempting to include mempool transactions
This means v5’s default behavior is inherently unsafe for rapid sequential transactions (you’ll get duplicate nonces), while v6’s default is better but still unreliable when the provider doesn’t properly support the "pending" block tag.

NonceManager: from experimental to core

In v5, the NonceManager lives in a separate package (@ethersproject/experimental) and must be installed independently. In v6, it’s part of the core ethers package. v5 usage:
import { ethers } from "ethers";
import { NonceManager } from "@ethersproject/experimental";

const provider = new ethers.providers.JsonRpcProvider("http://localhost:8545");
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const managed = new NonceManager(wallet);

// Sequential nonces assigned automatically
const tx1 = await managed.sendTransaction({ to: addr1, value: ethers.utils.parseEther("0.01") });
const tx2 = await managed.sendTransaction({ to: addr2, value: ethers.utils.parseEther("0.01") });
v6 usage:
import { ethers, NonceManager } from "ethers";

const provider = new ethers.JsonRpcProvider("http://localhost:8545");
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const managed = new NonceManager(wallet);

const tx1 = await managed.sendTransaction({ to: addr1, value: ethers.parseEther("0.01") });
const tx2 = await managed.sendTransaction({ to: addr2, value: ethers.parseEther("0.01") });

// Reset forces reload from chain (new in v6)
managed.reset();
Both versions work the same way internally: fetch the initial nonce from the network once, then maintain a local delta counter that increments synchronously with each sendTransaction() call. The delta is captured before the async nonce promise resolves, ensuring concurrent calls get sequential values. However, neither version handles failure recovery — if a transaction fails after the delta increments, a nonce gap is created.

Key API differences between v5 and v6

Aspectv5v6
Package@ethersproject/experimentalCore ethers
Nonce methodgetTransactionCount(blockTag)getNonce(blockTag)
IncrementincrementTransactionCount(count?)increment()
ResetsetTransactionCount(value)reset() (clears & reloads)
Default block tag"latest""pending"
Provider constructornew ethers.providers.JsonRpcProvider(url)new ethers.JsonRpcProvider(url)
BigNumberethers.BigNumberNative bigint
InternalsPublic-ish _deltaCountPrivate #delta

Manual nonce control in ethers.js

For production SDKs, you’ll typically manage nonces externally and pass them explicitly:
// Works identically in v5 and v6 (adjust imports accordingly)
const nonce = await myNonceManager.acquireNonce(wallet.address);

const tx = await wallet.sendTransaction({
  to: recipient,
  value: ethers.parseEther("0.01"),
  nonce: nonce,  // explicit nonce overrides auto-fill
});

// For contract calls, nonce goes in the overrides object
const contract = new ethers.Contract(address, abi, wallet);
await contract.transfer(recipient, amount, { nonce: nonce });

Provider configuration for your own RPC node

When running your own node, v6’s JsonRpcProvider supports implicit request batching, which reduces latency when populating multiple transaction fields simultaneously:
const provider = new ethers.JsonRpcProvider("http://your-node:8545", undefined, {
  staticNetwork: true,     // Skip network auto-detection on each call
  batchMaxCount: 10,       // Batch up to 10 JSON-RPC calls per HTTP request
  batchStallTime: 10,      // Wait 10ms to collect requests before sending
});
This batching is new in v6 — v5’s JsonRpcProvider did not support it natively.

Production nonce architecture: Redis-backed queue pattern

The ethers.js NonceManager is adequate for simple sequential use cases but breaks down under concurrent load, failure scenarios, and distributed systems. Production SDKs need an external nonce management layer. The dominant pattern, used by thirdweb Engine and OpenZeppelin Defender, combines a Redis-backed atomic nonce counter with a transaction queue.

Architecture overview

┌────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  API Server │────▶│  Transaction    │────▶│  TX Workers     │
│  (accepts   │     │  Queue (Redis/  │     │  (per-wallet    │
│   requests) │     │  BullMQ)        │     │   sequential)   │
└────────────┘     └─────────────────┘     └────────┬────────┘

                   ┌─────────────────┐               │
                   │  Redis Nonce    │◀──────────────┘
                   │  Store          │  INCR atomically
                   │  (per-wallet    │  before signing
                   │   per-chain)    │
                   └─────────────────┘
The API server accepts transaction requests and persists them to a queue immediately (fast response to callers). Worker processes consume from the queue sequentially per wallet, acquiring nonces atomically from Redis, simulating transactions, signing, and submitting to the RPC node.

Redis-backed nonce manager implementation

The key insight is using Redis Lua scripts for atomic read-and-increment operations, eliminating race conditions without distributed locks:
import Redis from 'ioredis';
import { ethers } from 'ethers';

class RedisNonceManager {
  private redis: Redis;
  private provider: ethers.JsonRpcProvider;

  constructor(redisUrl: string, rpcUrl: string) {
    this.redis = new Redis(redisUrl);
    this.provider = new ethers.JsonRpcProvider(rpcUrl);
  }

  /** Sync nonce from on-chain state. Called on startup or after drift detection. */
  async syncNonce(walletAddress: string): Promise<number> {
    const onchainNonce = await this.provider.getTransactionCount(walletAddress, 'latest');
    const key = `nonce:${walletAddress.toLowerCase()}`;
    
    // Lua script: only update if on-chain nonce is higher than stored value
    const lua = `
      local current = redis.call('GET', KEYS[1])
      if current == false or tonumber(current) < tonumber(ARGV[1]) then
        redis.call('SET', KEYS[1], ARGV[1])
        return ARGV[1]
      end
      return current
    `;
    const result = await this.redis.eval(lua, 1, key, onchainNonce);
    return Number(result);
  }

  /** Atomically acquire next nonce. Lock-free via Lua script. */
  async acquireNonce(walletAddress: string): Promise<number> {
    const key = `nonce:${walletAddress.toLowerCase()}`;
    const lua = `
      local current = redis.call('GET', KEYS[1])
      if current == false then return nil end
      redis.call('SET', KEYS[1], tonumber(current) + 1)
      return current
    `;
    const result = await this.redis.eval(lua, 1, key);
    if (result === null) {
      await this.syncNonce(walletAddress);
      return this.acquireNonce(walletAddress);
    }
    return Number(result);
  }

  /** Recycle nonce if tx failed before broadcast (only if it's the highest). */
  async recycleNonce(walletAddress: string, nonce: number): Promise<void> {
    const key = `nonce:${walletAddress.toLowerCase()}`;
    const lua = `
      local current = tonumber(redis.call('GET', KEYS[1]))
      if current == tonumber(ARGV[1]) + 1 then
        redis.call('SET', KEYS[1], ARGV[1])
        return 1
      end
      return 0
    `;
    await this.redis.eval(lua, 1, key, nonce);
  }
}

Transaction worker with retry and recovery

async function processTransaction(
  wallet: ethers.Wallet,
  nonceManager: RedisNonceManager,
  txReq: { to: string; data: string; value: bigint; id: string }
): Promise<string> {
  const nonce = await nonceManager.acquireNonce(wallet.address);

  try {
    // Simulate first — failed simulation should NOT consume the nonce
    const tx: ethers.TransactionRequest = {
      to: txReq.to,
      data: txReq.data,
      value: txReq.value,
      nonce,
    };
    await wallet.provider!.estimateGas({ ...tx, from: wallet.address });

    // Sign and send
    const response = await wallet.sendTransaction(tx);
    return response.hash;
  } catch (err: any) {
    // Recycle nonce only if tx was never broadcast to the network
    if (!err.receipt && !err.transactionHash) {
      await nonceManager.recycleNonce(wallet.address, nonce);
    }
    throw err;
  }
}

Recovering from stuck transactions and nonce gaps

When monitoring detects a stuck transaction (pending longer than a threshold), the SDK should either speed it up or cancel it:
async function cancelStuckTransaction(
  wallet: ethers.Wallet,
  stuckNonce: number,
  originalMaxFeePerGas: bigint,
  originalMaxPriorityFeePerGas: bigint
): Promise<ethers.TransactionResponse> {
  // Bump by 12% (safely above Geth's 10% minimum)
  const maxFeePerGas = (originalMaxFeePerGas * 112n) / 100n;
  const maxPriorityFeePerGas = (originalMaxPriorityFeePerGas * 112n) / 100n;

  return wallet.sendTransaction({
    to: wallet.address,   // self-transfer = cancellation
    value: 0n,
    nonce: stuckNonce,
    gasLimit: 21000n,
    maxFeePerGas,
    maxPriorityFeePerGas,
    type: 2,
  });
}

async function fillNonceGaps(
  wallet: ethers.Wallet,
  gapNonces: number[]
): Promise<string[]> {
  const feeData = await wallet.provider!.getFeeData();
  const hashes: string[] = [];

  for (const nonce of gapNonces) {
    const tx = await wallet.sendTransaction({
      to: wallet.address,
      value: 0n,
      nonce,
      gasLimit: 21000n,
      maxFeePerGas: feeData.maxFeePerGas! * 2n,
      maxPriorityFeePerGas: feeData.maxPriorityFeePerGas! * 2n,
    });
    hashes.push(tx.hash);
  }
  return hashes;
}
For gap detection, periodically compare your local next nonce against the node’s getTransactionCount("latest") and check pending transaction receipts. Any transaction pending longer than 5 minutes with a null receipt is likely dropped and may need resubmission or gap-filling.

Scaling beyond one wallet: multi-user and high-throughput patterns

For an SDK serving multiple customers, each wallet address has its own independent nonce sequence. Key the nonce store as nonce:{chainId}:{walletAddress} to ensure complete isolation. One user’s stuck transaction cannot affect another’s nonce sequence. For high-throughput systems that exceed the practical limit of one wallet, the proven pattern is a hot wallet pool. OpenZeppelin Defender recommends no more than 50 transactions per minute per relayer wallet, especially on fast L2 chains. For 250 tx/min, use 5 wallets with a load balancer distributing requests across them. thirdweb Engine v2 demonstrated that this architecture with Redis-backed nonces achieves hundreds of transactions per second. Key design principles for production:
  • Never trust eth_getTransactionCount("pending") for high-throughput — maintain your own atomic counter in Redis
  • Always simulate before sending via estimateGas — failed simulations should recycle the nonce, not consume it
  • Separate fast path from durable storage — Redis for atomic nonce operations, PostgreSQL for transaction history and audit trail
  • Implement idempotency keys — clients provide a unique key per request; the system checks for duplicates before processing, preventing double-submission on retries
  • Sync on every startup — initialize nonce from getTransactionCount("latest"), verify all pending transactions from your database against on-chain receipts, and reconcile gaps
  • Monitor nonce health — track drift between local and on-chain nonce, age of oldest pending transaction, gap count, wallet balance, and transaction throughput per wallet

Conclusion

The Ethereum nonce is deceptively simple in specification — a sequential counter with strict equality validation — but produces a combinatorial explosion of edge cases in production systems. The most important architectural insight is that the RPC node’s pending nonce is fundamentally unreliable for concurrent transaction submission, whether due to load-balanced endpoint inconsistency, provider-specific behavior differences, or mempool propagation delays. Every production SDK should maintain its own nonce counter with atomic operations (Redis Lua scripts being the dominant solution), implement transaction simulation before nonce commitment, and build automated recovery for the inevitable stuck transactions and nonce gaps. The ethers.js NonceManager provides a reasonable starting point for single-threaded applications, but its lack of failure recovery, persistence, and distributed coordination means it should be replaced with a custom implementation for any SDK handling concurrent transactions or multiple wallets.