Skip to Content
NodesManaging Nonces

Managing Nonces

This guide outlines strategies to manage transaction nonces safely on Arbitrum (Nitro), with a focus on ordering, concurrency, and recovery. It includes minimal viem examples and applies to both frontend and backend systems.

What is a nonce?

  • In EVM-compatible blockchains, a nonce is the count of transactions sent from an address.
  • Each new transaction must use a unique, sequential nonce:
    • The first transaction uses nonce 0.
    • The next uses nonce 1, and so on.
  • If a transaction uses a nonce that’s already been used or skipped, the blockchain will reject it.

Common challenges

  • Race conditions: Sending multiple transactions simultaneously may reuse the same nonce.
  • RPC delays: Fetching nonces from a slow RPC endpoint may lead to stale values.
  • Page reloads: If the app restarts, in-memory nonce tracking resets.

To avoid these, implement a clear source of truth, local tracking, and safe concurrency controls.

Strategy

We’ll use a two-layer nonce management system:

  • Remote layer: Fetches the latest confirmed nonce from the blockchain via RPC.
  • Local layer: Tracks and increments nonces in localStorage or in-memory state for pending transactions.

Flow:

Blockchain (RPC) Fetch latest confirmed nonce Store locally (nonce cache) Increment locally for each new tx Submit tx → update nonce tracker

Best practices

Arbitrum (Nitro) specifics

  • Nonce mismatches on Arbitrum commonly happen when the client optimistically bumps the nonce before the prior transaction is confirmed as included.
  • When a transaction is submitted with a nonce that is too high on Nitro, the node will hold it briefly (about 1 second by default) to see if intermediate transactions arrive to fill the gap. If they don’t, you’ll receive a “nonce too high” error afterwards.
  • That 1-second hold can delay feedback and encourage clients to keep submitting higher nonces. Until client-side logic is improved, reduce this “gap-hold” time from ~1s to ~0 to get immediate feedback. This allows you to quickly detect incorrect nonces before sending further transactions with even higher nonces.
  • Practical approach:
    • Gate sending of nonce N+1 on evidence that N is included (or at least that N is accepted and visible in pending).
    • If you receive “nonce too high”, immediately re-sync from RPC and pause higher-nonce submissions until the gap is resolved.
    • Ask your node/provider to minimize the hold window so errors surface immediately (Conduit can configure this on managed stacks).

Example (TypeScript script): Gated nonces on Arbitrum Nitro

import { createPublicClient, createWalletClient, http, parseEther, type Address } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { arbitrum } from "viem/chains"; // 1) Configure clients for Arbitrum Nitro (Node.js script) const RPC_URL = process.env.RPC_URL || "https://arb1.arbitrum.io/rpc"; const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`; if (!PRIVATE_KEY) { throw new Error("Missing PRIVATE_KEY env var (hex string, 0x-prefixed)."); } const account = privateKeyToAccount(PRIVATE_KEY); const publicClient = createPublicClient({ chain: arbitrum, transport: http(RPC_URL) }); const walletClient = createWalletClient({ account, chain: arbitrum, transport: http(RPC_URL) }); const address = account.address; // 2) Helpers async function getPendingNonce(addr: Address) { return publicClient.getTransactionCount({ address: addr, blockTag: "pending" }); } async function waitUntilPendingNonceAtLeast(addr: Address, target: number, pollMs = 300, timeoutMs = 6000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const pending = await getPendingNonce(addr); if (pending >= target) return true; await new Promise((r) => setTimeout(r, pollMs)); } return false; } // 3) Gated send: explicit nonce, then wait for pending to advance export async function sendWithGatedNonce(to: Address, valueEth = "0.01") { let next = await getPendingNonce(address); try { const hash = await walletClient.sendTransaction({ to, value: parseEther(valueEth), nonce: next, }); // Wait for mempool to reflect acceptance before attempting N+1 await waitUntilPendingNonceAtLeast(address, next + 1); return hash; } catch (err: any) { const msg = String(err?.message || "").toLowerCase(); if (msg.includes("nonce too high")) { const resynced = await getPendingNonce(address); throw new Error(`Nonce too high. Resynced pending nonce=${resynced}`); } if (msg.includes("nonce too low")) { const resynced = await getPendingNonce(address); throw new Error(`Nonce too low. Resynced pending nonce=${resynced}`); } throw err; } } // 4) Example usage // RPC_URL="https://arb1.arbitrum.io/rpc" PRIVATE_KEY="0x..." node script.ts // await sendWithGatedNonce("0xRecipientAddressHere" as Address, "0.01");

Source of truth: latest vs pending

  • Use latest to align with confirmed state (stable, conservative).
  • Use pending to include mempool (higher throughput, risk of drift).
  • For parallel send flows, prefer pending plus a local tracker to avoid reuse.

Fetching the nonce (viem)

// Latest confirmed nonce const latest = await client.getTransactionCount({ address, blockTag: "latest" }); // Include mempool transactions const pending = await client.getTransactionCount({ address, blockTag: "pending" });

Local tracking and persistence

Maintain a next-nonce per address locally

Recommended algorithm (per address):

  1. On startup, read pending nonce from RPC as the baseline.
  2. Compare with any locally cached value and keep the max.
  3. On each send:
    • Read current local next-nonce.
    • Reserve it (optimistically).
    • Submit tx with that nonce.
    • Increment and persist the next-nonce.
  4. On error indicating nonce drift, re-sync from RPC and update local store.

Minimal usage pattern:

const next = await takeNextNonce(address); // reserves and returns next nonce await walletClient.sendTransaction({ ...tx, nonce: next });

Concurrency control (avoid races)

  • Use a per-address mutex/lock (backend) or single-flight queue (frontend).
  • Only one “allocate nonce and send” critical section should run at a time.
await withAddressLock(address, async () => { const next = await takeNextNonce(address); const hash = await walletClient.sendTransaction({ ...tx, nonce: next }); return hash; });

Handling errors and re-sync

  • “nonce too low”: Your local tracker is behind; re-fetch pending, set local to max, retry.
  • “replacement transaction underpriced”: If intentionally replacing, bump fees sufficiently; otherwise choose a new higher nonce.
  • Dropped transactions: If a tx is dropped, you may reuse its nonce after confirming it’s not in mempool; safer is to send a replacement with higher fee.
  • Periodically re-sync from RPC (timer or after N sends).

Replacement transactions (EIP-1559)

  • To replace a pending tx, send a new tx with the same nonce and a higher effective priority fee/tip.
  • Ensure the replacement increases fees enough to be accepted by the node/p2p policy.

When to rely on node-managed nonces

  • It’s fine to omit nonce and let the node set it if:
    • You send strictly sequential transactions (no parallelism).
    • Throughput and latency requirements are modest.
  • If you need parallelism or precise control, manage nonces locally.

Client implementation (React + viem)

Install

npm install viem

Hook: useNonceManager

import { useCallback, useEffect, useRef, useState } from "react"; import type { Address, PublicClient } from "viem"; const NONCE_STORAGE_KEY = "nonce-tracker"; export function useNonceManager(client: PublicClient, address?: Address) { const [nonce, setNonce] = useState<number | null>(null); const isFetching = useRef(false); const fetchOnChainNonce = useCallback(async () => { if (!address || isFetching.current) return; isFetching.current = true; try { const latest = await client.getTransactionCount({ address, blockTag: "latest", }); setNonce(latest); localStorage.setItem(`${NONCE_STORAGE_KEY}:${address}`, String(latest)); } finally { isFetching.current = false; } }, [client, address]); const loadLocalNonce = useCallback(() => { if (!address) return; const saved = localStorage.getItem(`${NONCE_STORAGE_KEY}:${address}`); if (saved) setNonce(Number(saved)); }, [address]); const getNextNonce = useCallback(() => { if (nonce === null) throw new Error("Nonce not initialized"); const next = nonce; const newNonce = nonce + 1; setNonce(newNonce); if (address) { localStorage.setItem(`${NONCE_STORAGE_KEY}:${address}`, String(newNonce)); } return next; }, [nonce, address]); const resetNonce = useCallback(() => { setNonce(null); if (address) localStorage.removeItem(`${NONCE_STORAGE_KEY}:${address}`); }, [address]); useEffect(() => { loadLocalNonce(); fetchOnChainNonce(); }, [loadLocalNonce, fetchOnChainNonce]); return { nonce, getNextNonce, fetchOnChainNonce, resetNonce }; }

Component: submit a transaction on Arbitrum

import React from "react"; import { createPublicClient, createWalletClient, custom, http, parseEther, type Address, } from "viem"; import { arbitrum } from "viem/chains"; import { useNonceManager } from "./hooks/useNonceManager"; export function TransactionSender() { const publicClient = createPublicClient({ chain: arbitrum, transport: http("https://arb1.arbitrum.io/rpc"), }); const walletClient = createWalletClient({ chain: arbitrum, // In a browser, prefer the injected provider (e.g., MetaMask) transport: typeof window !== "undefined" && (window as any).ethereum ? custom((window as any).ethereum) : http("https://arb1.arbitrum.io/rpc"), }); const address = "0xYourAddressHere" as Address; const { nonce, getNextNonce, fetchOnChainNonce } = useNonceManager( publicClient, address ); const sendTransaction = async () => { // Avoid optimistic bumps: only allocate the next nonce when sending const currentNonce = getNextNonce(); const tx = { to: "0xRecipientAddress" as Address, value: parseEther("0.01"), nonce: currentNonce, gas: 21000n, }; const hash = await walletClient.sendTransaction(tx); console.log("Tx sent:", hash); // Optionally re-sync after submission fetchOnChainNonce(); }; return ( <div className="p-4"> <p>Current Nonce: {nonce ?? "Loading..."}</p> <button onClick={sendTransaction}>Send Transaction</button> </div> ); }

Arbitrum tip: gate sending of nonce N+1 until N is visible in pending (or confirmed), and avoid incrementing local state until you actually send. If you receive “nonce too high”, immediately re-sync from RPC and pause higher-nonce submissions; ask your provider to minimize the Nitro hold window so errors surface immediately.

Summary

TaskLayerSource
Initial nonceBlockchainclient.getTransactionCount({ address, blockTag: "pending" })
Cached nonceLocallocalStorage
Increment between txsLocalnonce + 1
Sync periodicallyRemotefetchOnChainNonce()

By combining local caching and on-chain syncing, your dApp can reliably manage nonces even in complex transaction flows.

Last updated on