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
1import {
2 createPublicClient,
3 createWalletClient,
4 http,
5 parseEther,
6 type Address,
7} from "viem";
8import { privateKeyToAccount } from "viem/accounts";
9import { arbitrum } from "viem/chains";
10
11// 1) Configure clients for Arbitrum Nitro (Node.js script)
12const RPC_URL = process.env.RPC_URL || "https://arb1.arbitrum.io/rpc";
13const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`;
14if (!PRIVATE_KEY) {
15 throw new Error("Missing PRIVATE_KEY env var (hex string, 0x-prefixed).");
16}
17const account = privateKeyToAccount(PRIVATE_KEY);
18
19const publicClient = createPublicClient({
20 chain: arbitrum,
21 transport: http(RPC_URL),
22});
23const walletClient = createWalletClient({
24 account,
25 chain: arbitrum,
26 transport: http(RPC_URL),
27});
28const address = account.address;
29
30// 2) Helpers
31async function getPendingNonce(addr: Address) {
32 return publicClient.getTransactionCount({
33 address: addr,
34 blockTag: "pending",
35 });
36}
37
38async function waitUntilPendingNonceAtLeast(
39 addr: Address,
40 target: number,
41 pollMs = 300,
42 timeoutMs = 6000,
43) {
44 const start = Date.now();
45 while (Date.now() - start < timeoutMs) {
46 const pending = await getPendingNonce(addr);
47 if (pending >= target) return true;
48 await new Promise((r) => setTimeout(r, pollMs));
49 }
50 return false;
51}
52
53// 3) Gated send: explicit nonce, then wait for pending to advance
54export async function sendWithGatedNonce(to: Address, valueEth = "0.01") {
55 let next = await getPendingNonce(address);
56 try {
57 const hash = await walletClient.sendTransaction({
58 to,
59 value: parseEther(valueEth),
60 nonce: next,
61 });
62 // Wait for mempool to reflect acceptance before attempting N+1
63 await waitUntilPendingNonceAtLeast(address, next + 1);
64 return hash;
65 } catch (err: any) {
66 const msg = String(err?.message || "").toLowerCase();
67 if (msg.includes("nonce too high")) {
68 const resynced = await getPendingNonce(address);
69 throw new Error(`Nonce too high. Resynced pending nonce=${resynced}`);
70 }
71 if (msg.includes("nonce too low")) {
72 const resynced = await getPendingNonce(address);
73 throw new Error(`Nonce too low. Resynced pending nonce=${resynced}`);
74 }
75 throw err;
76 }
77}
78
79// 4) Example usage
80// RPC_URL="https://arb1.arbitrum.io/rpc" PRIVATE_KEY="0x..." node script.ts
81// 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
1// Latest confirmed nonce
2const latest = await client.getTransactionCount({
3 address,
4 blockTag: "latest",
5});
6
7// Include mempool transactions
8const pending = await client.getTransactionCount({
9 address,
10 blockTag: "pending",
11});

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:

1const next = await takeNextNonce(address); // reserves and returns next nonce
2await 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.
1await withAddressLock(address, async () => {
2 const next = await takeNextNonce(address);
3 const hash = await walletClient.sendTransaction({ ...tx, nonce: next });
4 return hash;
5});

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
1import { useCallback, useEffect, useRef, useState } from "react";
2import type { Address, PublicClient } from "viem";
3
4const NONCE_STORAGE_KEY = "nonce-tracker";
5
6export function useNonceManager(client: PublicClient, address?: Address) {
7 const [nonce, setNonce] = useState<number | null>(null);
8 const isFetching = useRef(false);
9
10 const fetchOnChainNonce = useCallback(async () => {
11 if (!address || isFetching.current) return;
12 isFetching.current = true;
13 try {
14 const latest = await client.getTransactionCount({
15 address,
16 blockTag: "latest",
17 });
18 setNonce(latest);
19 localStorage.setItem(`${NONCE_STORAGE_KEY}:${address}`, String(latest));
20 } finally {
21 isFetching.current = false;
22 }
23 }, [client, address]);
24
25 const loadLocalNonce = useCallback(() => {
26 if (!address) return;
27 const saved = localStorage.getItem(`${NONCE_STORAGE_KEY}:${address}`);
28 if (saved) setNonce(Number(saved));
29 }, [address]);
30
31 const getNextNonce = useCallback(() => {
32 if (nonce === null) throw new Error("Nonce not initialized");
33 const next = nonce;
34 const newNonce = nonce + 1;
35 setNonce(newNonce);
36 if (address) {
37 localStorage.setItem(`${NONCE_STORAGE_KEY}:${address}`, String(newNonce));
38 }
39 return next;
40 }, [nonce, address]);
41
42 const resetNonce = useCallback(() => {
43 setNonce(null);
44 if (address) localStorage.removeItem(`${NONCE_STORAGE_KEY}:${address}`);
45 }, [address]);
46
47 useEffect(() => {
48 loadLocalNonce();
49 fetchOnChainNonce();
50 }, [loadLocalNonce, fetchOnChainNonce]);
51
52 return { nonce, getNextNonce, fetchOnChainNonce, resetNonce };
53}

Component: submit a transaction on Arbitrum

copy
1import React from "react";
2import {
3 createPublicClient,
4 createWalletClient,
5 custom,
6 http,
7 parseEther,
8 type Address,
9} from "viem";
10import { arbitrum } from "viem/chains";
11import { useNonceManager } from "./hooks/useNonceManager";
12
13export function TransactionSender() {
14 const publicClient = createPublicClient({
15 chain: arbitrum,
16 transport: http("https://arb1.arbitrum.io/rpc"),
17 });
18 const walletClient = createWalletClient({
19 chain: arbitrum,
20 // In a browser, prefer the injected provider (e.g., MetaMask)
21 transport:
22 typeof window !== "undefined" && (window as any).ethereum
23 ? custom((window as any).ethereum)
24 : http("https://arb1.arbitrum.io/rpc"),
25 });
26
27 const address = "0xYourAddressHere" as Address;
28 const { nonce, getNextNonce, fetchOnChainNonce } = useNonceManager(
29 publicClient,
30 address,
31 );
32
33 const sendTransaction = async () => {
34 // Avoid optimistic bumps: only allocate the next nonce when sending
35 const currentNonce = getNextNonce();
36
37 const tx = {
38 to: "0xRecipientAddress" as Address,
39 value: parseEther("0.01"),
40 nonce: currentNonce,
41 gas: 21000n,
42 };
43
44 const hash = await walletClient.sendTransaction(tx);
45 console.log("Tx sent:", hash);
46
47 // Optionally re-sync after submission
48 fetchOnChainNonce();
49 };
50
51 return (
52 <div className="p-4">
53 <p>Current Nonce: {nonce ?? "Loading..."}</p>
54 <button onClick={sendTransaction}>Send Transaction</button>
55 </div>
56 );
57}

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.