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

copy
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)

copy
// 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

copy
npm install viem

Hook: useNonceManager

copy
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

copy
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.