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 trackerBest 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).
- Gate sending of nonce N+1 on evidence that N is included (or at least that N is accepted and visible in
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
latestto align with confirmed state (stable, conservative). - Use
pendingto include mempool (higher throughput, risk of drift). - For parallel send flows, prefer
pendingplus 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):
- On startup, read
pendingnonce from RPC as the baseline. - Compare with any locally cached value and keep the max.
- On each send:
- Read current local next-nonce.
- Reserve it (optimistically).
- Submit tx with that nonce.
- Increment and persist the next-nonce.
- 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
nonceand 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 viemHook: 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
| Task | Layer | Source |
|---|---|---|
| Initial nonce | Blockchain | client.getTransactionCount({ address, blockTag: "pending" }) |
| Cached nonce | Local | localStorage |
| Increment between txs | Local | nonce + 1 |
| Sync periodically | Remote | fetchOnChainNonce() |
By combining local caching and on-chain syncing, your dApp can reliably manage nonces even in complex transaction flows.