Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
RPC Troubleshooting12 min read

EVM Nonce Management: Using eth_getTransactionCount Correctly Under Concurrency

Learn how to manage EVM nonces correctly with eth_getTransactionCount to avoid nonce too low, stuck transactions, and concurrency issues.

TL;DR

To manage EVM nonces correctly under concurrency, treat the nonce as application state: seed once from eth_getTransactionCount(addr, 'pending'), then increment a local counter for each broadcast. Avoid reading 'pending' for every transaction because it can be stale or race-prone, leading to 'nonce too low' or stuck transactions. Re-sync only after reconnects or reorgs, and handle errors like 'replacement transaction underpriced' with a gas price bump.

The Direct Answer: Nonce Is Application State, Not a Per-Request RPC Call

The correct way to use eth_getTransactionCount under concurrency is to not call it for every transaction you send. Instead, treat the nonce as application state: seed it once from eth_getTransactionCount(ADDRESS, 'pending'), then maintain a local monotonic counter that you increment for each broadcast. Only re-sync from the node after a reconnect, a reorg, or if you lose track of in-flight transactions. This avoids the classic race where multiple concurrent senders each read the same 'pending' nonce and then collide, producing 'nonce too low' errors or stuck transactions.

This article explains the mechanism behind the nonce, why the 'pending' tag is both powerful and dangerous, and gives you a reproducible Node.js project to see the failure and the fix. It also includes a decision table for common JSON-RPC error strings and their remedies.

How the EVM Nonce Works: A Per-Account Scalar

In the Ethereum protocol, every transaction from an externally-owned account (EOA) carries a nonce field: a sequential, per-account scalar that starts at 0 for the first transaction. The Ethereum yellow paper defines the account state as including a nonce, and the execution-apis specification for eth_getTransactionCount states that it returns the number of transactions sent from an address—which is exactly the next nonce you should use.

The node maintains two views of this count: the committed state (what is in canonical blocks) and the pending state (committed plus what is in the node's mempool). The eth_getTransactionCount method accepts a block parameter: 'latest' (the default) returns the committed count, 'pending' includes transactions in the mempool, and a hex block number returns the count at a specific historical block.

The critical insight is that 'pending' is not a global truth—it is the mempool view of the specific node you are querying. Different nodes (and different providers) may have different mempools, especially during propagation delays or after a reorg. If you rely on 'pending' for every send, you might get a stale value that has already been used by another sender, or you might miss a transaction that another node has already seen.

Why Concurrent Sends Fail: The Race Condition

Consider a simple script that spawns N parallel senders, each calling eth_getTransactionCount(ADDRESS, 'pending') and then broadcasting a transaction with that nonce. Because the calls happen concurrently, they are likely to receive the same nonce value. The first transaction gets mined, but the others are rejected with 'nonce too low' because that nonce is already used.

Even if you serialize the reads, the node's pending view might not yet include your just-broadcast transaction due to propagation delay. This is why reading 'pending' for every send is fundamentally racy.

The safe pattern is to have a single writer (or a distributed lock) that owns the nonce counter. Seed it once, increment locally, and only re-sync when you suspect the local counter is out of sync (e.g., after a reorg or a dropped transaction).

Failure Modes and How to Recover

When you send a transaction with an incorrect nonce, the node returns a JSON-RPC error. Here are the common ones and how to handle them:

'nonce too low' – The nonce you used is already in the canonical chain or in the mempool. This usually means you double-counted. Recovery: re-sync your local counter from eth_getTransactionCount(ADDRESS, 'pending') and rebroadcast with the correct nonce.

'nonce too high' – You skipped a nonce, creating a gap. The node will queue your transaction but it will not be mined until all lower nonces are filled. Recovery: send the missing lower-nonce transaction first, or cancel the gap by sending a transaction with the missing nonce (e.g., a 0-value transfer to yourself).

'replacement transaction underpriced' – You tried to replace a pending transaction with the same nonce but with a gas price that is not high enough. The Ethereum replacement rule requires a price bump of at least ~10% (the exact percentage is not in the protocol spec but is enforced by nodes like Geth). Recovery: re-broadcast with a higher gas price (e.g., 10-20% more).

Reorgs and dropped transactions – After a reorg, the node's view of 'pending' may move backward, and transactions that were in the mempool may be dropped. If your local counter is ahead of the node's, you will get 'nonce too high'. Recovery: re-sync from 'pending' and be prepared to resend any transactions that were lost.

Reproducible Node.js Project: See the Bug and the Fix

The following Node.js script uses ethers v6 (or raw JSON-RPC via fetch) to demonstrate the concepts. It requires a user-supplied endpoint (e.g., an OnFinality Ethereum endpoint) and a private key for an EOA with some funds.

The script has three modes: check prints the 'latest' and 'pending' nonce for an address; bug runs a concurrent send that demonstrates the race; safe runs the safe pattern with a local counter and retry logic.

Save the script as nonce-demo.js and run it with node nonce-demo.js <endpoint> <privateKey> <mode>.

const { ethers } = require('ethers');

async function main() {
  const endpoint = process.argv[2];
  const privateKey = process.argv[3];
  const mode = process.argv[4] || 'check';
  const provider = new ethers.JsonRpcProvider(endpoint);
  const wallet = new ethers.Wallet(privateKey, provider);
  const address = wallet.address;

  if (mode === 'check') {
    const latest = await provider.getTransactionCount(address, 'latest');
    const pending = await provider.getTransactionCount(address, 'pending');
    console.log(`Latest nonce: ${latest}`);
    console.log(`Pending nonce: ${pending}`);
    return;
  }

  if (mode === 'bug') {
    // Concurrent sends: each reads pending nonce and sends
    const senders = [];
    for (let i = 0; i < 5; i++) {
      senders.push((async () => {
        const nonce = await provider.getTransactionCount(address, 'pending');
        try {
          const tx = await wallet.sendTransaction({
            to: address, // send to self
            value: 0,
            nonce,
            gasLimit: 21000,
            maxFeePerGas: ethers.parseUnits('1', 'gwei'),
            maxPriorityFeePerGas: ethers.parseUnits('1', 'gwei')
          });
          console.log(`Sent with nonce ${nonce}: ${tx.hash}`);
        } catch (e) {
          console.log(`Failed with nonce ${nonce}: ${e.shortMessage || e.message}`);
        }
      })());
    }
    await Promise.all(senders);
    return;
  }

  if (mode === 'safe') {
    // Seed once
    let nextNonce = await provider.getTransactionCount(address, 'pending');
    console.log(`Starting nonce: ${nextNonce}`);

    // Serialize sends with a simple queue
    const sendQueue = [];
    for (let i = 0; i < 5; i++) {
      sendQueue.push((async () => {
        const nonce = nextNonce++;
        let attempt = 0;
        while (attempt < 3) {
          try {
            const tx = await wallet.sendTransaction({
              to: address,
              value: 0,
              nonce,
              gasLimit: 21000,
              maxFeePerGas: ethers.parseUnits('1', 'gwei'),
              maxPriorityFeePerGas: ethers.parseUnits('1', 'gwei')
            });
            console.log(`Sent with nonce ${nonce}: ${tx.hash}`);
            return;
          } catch (e) {
            const msg = e.shortMessage || e.message;
            if (msg.includes('replacement transaction underpriced')) {
              // Bump gas price by 20%
              attempt++;
              const newGas = ethers.parseUnits((1 + attempt * 0.2).toFixed(2), 'gwei');
              console.log(`Underpriced, retrying with gas ${newGas}`);
              // Re-send with higher gas (simplified: need to recreate tx)
              // In practice, you'd use a higher fee and same nonce
            } else if (msg.includes('nonce too low')) {
              // Re-sync
              nextNonce = await provider.getTransactionCount(address, 'pending');
              console.log(`Nonce too low, re-synced to ${nextNonce}`);
              return;
            } else {
              console.log(`Failed: ${msg}`);
              return;
            }
          }
        }
      })();
    }
    await Promise.all(sendQueue);
  }
}

main().catch(console.error);

Expected Output and Results Table

When you run the check mode, you will see two numbers. The difference between them indicates how many transactions are currently in the mempool for that address. For example:

This means two transactions are pending (nonces 5 and 6).

The bug mode will likely produce several 'nonce too low' errors because all senders read the same pending nonce. The safe mode should send all transactions successfully with sequential nonces.

Fill in the table below with your own results to verify the behavior:

  • Mode | Observed Output | Explanation
  • check | Latest: X, Pending: Y | Y - X = number of in-flight txs
  • bug | Multiple 'nonce too low' | Race condition: all read same nonce
  • safe | All sent with sequential nonces | Local counter prevents collision
Latest nonce: 5
Pending nonce: 7

Error String Decision Table

The following table maps common JSON-RPC error messages to their cause and the recommended fix. Use it as a quick reference when debugging nonce issues.

  • Error | Cause | Fix
  • nonce too low | Nonce already used (mined or in mempool) | Re-sync from 'pending' and rebroadcast with correct nonce
  • nonce too high | Gap in nonce sequence | Send the missing lower nonce first, or cancel the gap
  • replacement transaction underpriced | Replacing a pending tx with insufficient gas price bump | Re-broadcast with at least ~10% higher gas price
  • transaction underpriced | Gas price below node's minimum | Increase gas price to meet node minimum
  • already known | Transaction already in mempool | Wait for confirmation or replace with higher gas
  • nonce too low after reorg | Node's view moved backward | Re-sync nonce from 'pending' and resend lost txs

Limitations and Tradeoffs

Mempool visibility varies across providers and node clients. A transaction might be visible on one node but not another, so 'pending' is not a global truth. Always use a reliable endpoint and consider using a dedicated RPC provider like OnFinality's Ethereum API for consistent behavior.

Replacement policies are designed for price bumps, not identical re-sends. If you send the same nonce with the same gas price, the node will reject it as 'replacement transaction underpriced'. Always bump the gas price by at least 10% when replacing.

Never assume 'pending' is instantly consistent after a reorg. The node may take time to rebuild its mempool. If you are in a high-stakes environment, consider waiting a few seconds after a reorg before re-syncing.

For high-throughput applications, consider using a local nonce manager or a library like ethers' NonceManager (though it has its own limitations). The key is to centralize nonce allocation.

Next Steps and Further Reading

Now that you understand nonce management, you can apply these patterns to your own applications. For more RPC troubleshooting, explore the OnFinality Learn hub for guides on Ethereum RPC timeouts and retries, JSON-RPC batching best practices, and monitoring RPC endpoints.

If you are building on Ethereum, also check out Choosing an Ethereum RPC endpoint and Decoding Ethereum revert reasons. For production, consider using a dedicated API service with RPC pricing that meets your needs.

Remember: the nonce is a simple scalar, but managing it correctly under concurrency is a classic distributed systems problem. Treat it as state, not as a per-request query, and you will avoid most pitfalls.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started