Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Integration & API Design13 min read

JSON-RPC Idempotency: Preventing Duplicate Writes on Retry

Why a timed-out JSON-RPC write may already have executed, and how to build an at-most-once client using nonces, transaction hashes, and check-then-resend.

TL;DR

A JSON-RPC timeout or dropped connection does not mean the request failed: the node may already have applied your state-changing call, so a naive retry can double-submit a transfer, mint, or order. JSON-RPC 2.0 has no built-in idempotency key; the id field only correlates a response and does not deduplicate server-side. Safe retries come from chain-native primitives: the EVM account nonce, the deterministic transaction hash you can poll with eth_getTransactionReceipt, Solana signatures with durable nonces, and exchange client-order-ids. Build an at-most-once client by persisting a deterministic operation id with the signed payload before sending, recording the resulting hash, and checking whether that hash already landed before re-broadcasting.

Why a Timed-Out JSON-RPC Write Is Not a Failed Write

The core hazard of retrying JSON-RPC writes is a false assumption: that a timeout, a reset connection, or a 5xx response means the node did not apply your request. In practice the request may have been accepted, signed, and broadcast, and only the response was lost on the way back. The client sees an error; the chain sees a transaction. Retrying blindly then produces a second application: two transfers, two mints, two orders.

This is the classic production bug behind duplicated payouts and double-filled orders. It is not specific to any one provider; it is a property of any request/response protocol layered over an unreliable network. The RPC timeout errors: causes and fixes guide covers the transport side; this article covers the correctness side, which is what stops the duplicate write.

The practical rule: for read methods, retry freely. For state-changing methods, treat every ambiguous outcome as 'possibly applied' until you have read chain state to prove otherwise. That single discipline prevents most duplicate-write incidents.

  • Timeout or connection reset = unknown outcome, not failure.
  • The node may have applied the call and lost only the response.
  • A naive retry of a write can double-apply the effect.
  • Resolution requires reading chain state, not re-sending.

What JSON-RPC 2.0 Actually Guarantees About the id Field

The JSON-RPC 2.0 specification defines id as a request identifier used to correlate a response with its request. It is a client-chosen value, and the spec says nothing about the server using it to deduplicate work. Reusing the same id on a retry does not stop a second application; it only helps you match the response to the call you made.

The spec also defines notifications: requests without an id, for which the server returns no response. Notifications are fire-and-forget and are strictly worse for retry safety, because you cannot even correlate an outcome. For state-changing calls, always send an id and always expect a response you can reason about.

Because JSON-RPC has no idempotency-key concept, the HTTP convention of an Idempotency-Key header is not part of the base protocol. Some HTTP-fronted RPC gateways may support it, but you cannot assume it. The authoritative reference is the JSON-RPC 2.0 specification; the HTTP idempotency convention is described in the IETF draft on the Idempotency-Key header.

  • id correlates a response; it does not deduplicate server-side.
  • Notifications (no id) return no response and are unsafe for writes.
  • JSON-RPC 2.0 has no native idempotency key.
  • HTTP Idempotency-Key is a separate convention, not guaranteed by RPC nodes.

A Safety Taxonomy: Read Methods vs State-Changing Methods

Not all JSON-RPC methods carry the same retry risk. Read methods are naturally idempotent: calling eth_call, eth_getBalance, or getAccountInfo twice returns the same result for the same block context and changes nothing. State-changing methods are not idempotent: eth_sendRawTransaction, sendTransaction, and exchange order endpoints apply an effect each time they succeed.

Use this decision table before you write any retry logic. The reasoning column is the part that matters: it tells you why a method is safe or unsafe, so you can classify new methods yourself rather than memorizing a list.

When in doubt, classify a method as state-changing. The cost of an unnecessary check is a few extra reads; the cost of a wrong retry is a duplicated financial effect.

  • eth_call, eth_getBalance, eth_getTransactionReceipt, getAccountInfo: idempotent, safe to retry.
  • eth_sendRawTransaction, sendTransaction, exchange order submit: not idempotent, never blind-retry.
  • eth_getTransactionCount: idempotent read, but its value drives nonce correctness.
  • Batch requests: mixed safety; a partial failure forces per-sub-request reasoning.

Chain-Native Idempotency Primitives You Already Have

You do not need a custom idempotency key to make writes safely retryable; the chains provide primitives. On EVM chains, the account nonce makes a replacement transaction with the same nonce an idempotent cancel-or-replace rather than a second application. The transaction hash is a deterministic identity, so a client can poll eth_getTransactionReceipt by hash instead of blindly re-sending. The EVM nonce management under concurrency guide covers nonce races in depth.

On Solana, dedupe via the transaction signature, and use a durable nonce or a fresh blockhash check to control whether a re-signed transaction can land. The Solana RPC timeouts and retries guide covers the timeout behavior that makes this necessary.

Exchange and Hyperliquid-style APIs expose a client-order-id (cloid) pattern: you supply a unique client id, and the venue rejects or ignores a duplicate. That is the closest thing to a true idempotency key in trading APIs, and it is why you should always set it when the venue supports it.

  • EVM: same-nonce replacement is cancel-or-replace, not a second application.
  • EVM: transaction hash is deterministic; poll receipt by hash.
  • Solana: dedupe by signature; durable nonce or blockhash controls landing.
  • Exchanges: client-order-id / cloid is the venue-level dedupe key.

Building an At-Most-Once Client: Persist, Send, Record, Check

An at-most-once client follows four steps. First, generate a deterministic operation id from your business intent (for example, a hash of account, recipient, amount, and a business reference), not a random UUID per attempt. Second, persist that operation id together with the signed payload before you send anything. Third, on success, record the resulting chain transaction hash against the operation id. Fourth, on retry, first check whether the persisted hash already landed before re-broadcasting.

The persistence step is what makes the pattern survive a process crash. If you only keep state in memory, a restart loses the hash and you are back to guessing. A small durable store (a database row, a file, even a key-value store) is enough.

The check step is a read: eth_getTransactionReceipt by hash on EVM, getSignatureStatuses on Solana, or an order-status query on an exchange. If the receipt exists, the write already applied; do not re-send. If it does not exist and the nonce is still unused, you can safely re-broadcast the same signed payload.

  • Deterministic operation id derived from business intent, not random per attempt.
  • Persist id + signed payload before sending.
  • Record the chain tx hash on success.
  • On retry, check the hash first; re-broadcast only if it did not land.

Runnable Node.js Example: Check-Then-Resend Avoids a Double Write

The example below sends once, records the hash, simulates a timeout, and then demonstrates check-then-resend. It uses a placeholder RPC URL; point it at a testnet endpoint such as the Ethereum network page or any endpoint from the RPC endpoints guide (RPC Assistant). The key behavior is that the retry path reads the receipt before re-sending.

This is deliberately minimal. In production you would replace the in-memory store with a durable one and add nonce management, but the control flow is the same.

// check-then-resend.js — Node 18+, no external deps beyond ethers
import { JsonRpcProvider, Wallet, parseEther } from 'ethers';

const RPC_URL = process.env.RPC_URL; // e.g. a testnet endpoint
const provider = new JsonRpcProvider(RPC_URL);
const wallet = new Wallet(process.env.PRIVATE_KEY, provider);

// In-memory stand-in for a durable store.
const store = new Map();

async function sendOnce(opId, to, amountEth) {
  // 1. If we already recorded a hash, check whether it landed.
  const prior = store.get(opId);
  if (prior?.hash) {
    const receipt = await provider.getTransactionReceipt(prior.hash);
    if (receipt) {
      console.log('Already applied, not re-sending:', prior.hash);
      return receipt;
    }
    console.log('Prior hash not mined yet, re-broadcasting same payload');
    return provider.broadcastTransaction(prior.raw);
  }

  // 2. Build and sign once, persist BEFORE sending.
  const nonce = await provider.getTransactionCount(wallet.address, 'pending');
  const tx = await wallet.populateTransaction({ to, value: parseEther(amountEth), nonce });
  const raw = await wallet.signTransaction(tx);
  store.set(opId, { raw, hash: null });

  // 3. Send. A timeout here does NOT mean failure.
  try {
    const sent = await provider.broadcastTransaction(raw);
    store.set(opId, { raw, hash: sent.hash });
    console.log('Sent:', sent.hash);
    return sent;
  } catch (err) {
    console.log('Ambiguous outcome (timeout/reset):', err.message);
    // 4. Do NOT blindly re-send. Check chain state first.
    const hash = (await import('ethers')).keccak256(raw);
    const receipt = await provider.getTransactionReceipt(hash);
    if (receipt) {
      store.set(opId, { raw, hash });
      console.log('Found on chain despite error:', hash);
      return receipt;
    }
    console.log('Not found; safe to retry with same payload');
    return provider.broadcastTransaction(raw);
  }
}

await sendOnce('op-2026-09-13-001', '0x000000000000000000000000000000000000dEaD', '0.001');

A Reproducible Testnet Test: Naive Retry vs Guarded Retry

You can prove the hazard to yourself on a testnet. Run two variants against the same funded test account and compare the resulting balances and transaction counts. This is a measurement you perform, not a benchmark we assert; record your own numbers in the table below.

Variant A (naive): send a transfer, force a client-side timeout by aborting the request after a short delay, then immediately re-send the same logical transfer with a new nonce. Variant B (guarded): send the same transfer, force the same timeout, then check eth_getTransactionReceipt by the original hash before deciding whether to re-send.

Compare the recipient balance delta and the sender's transaction count. Variant A can show two applications; Variant B should show one. Run each variant several times and note variance, because timing determines whether the first transaction landed before the abort.

  • Results Table columns: Variant | Recipient delta | Sender tx count | Duplicate observed (Y/N) | Notes.
  • Run each variant at least 5 times; timing changes the outcome.
  • Use a fresh test account per run to avoid nonce carryover.
  • Record the RPC endpoint and client version alongside results.

Idempotency-Keyed HTTP Wrappers and What to Do Without One

Some HTTP-fronted APIs support an Idempotency-Key header, where the server stores the key and returns the original response for a repeat. If your endpoint documents this, use it: generate a deterministic key per business operation, send it on every attempt, and let the server deduplicate. This is a documented platform behavior, not a JSON-RPC guarantee.

When the endpoint does not support it, you must implement dedupe client-side using the chain-native primitives above. There is no shortcut: if neither the server nor the chain offers a dedupe key, the only safe resolution is to read state and decide. The API service and RPC pricing pages describe the service surface; check the specific endpoint documentation for whether an idempotency header is honored.

A useful pattern is a thin wrapper that always attaches your operation id as metadata and logs it, even when the server ignores it. That gives you an audit trail to reconcile duplicates after the fact.

  • Use Idempotency-Key only where the endpoint documents support.
  • Without server support, dedupe client-side via nonce/hash/signature.
  • Log your operation id even if the server ignores it.
  • Reconcile ambiguous outcomes by reading chain state.

Interaction With Request Hedging and Batch Requests

Request hedging sends the same call to multiple endpoints and takes the first response. It is safe for reads and dangerous for writes: hedging a write can cause the same transaction to be broadcast through two paths, and while the nonce usually prevents a double application on EVM, it can still produce confusing errors and wasted fees. The RPC request hedging and tail latency guide explicitly warns to hedge only read methods; treat that as a hard rule.

Batch requests compound the problem. A batch is a single HTTP request containing multiple JSON-RPC calls, and a partial failure means some sub-requests may have applied while others did not. You cannot assume the batch is atomic. On retry, reason about each sub-request individually using its own id and its own chain-native identity.

The safe pattern is to keep writes out of batches entirely, or to batch only reads and issue writes one at a time with explicit check-then-resend handling.

  • Never hedge a state-changing call.
  • Batches are not atomic; partial application is possible.
  • Reason per sub-request on retry, not per batch.
  • Prefer single writes with explicit dedupe over batched writes.

Limitations: Rejected vs Applied-But-Response-Lost

There is a fundamental boundary the client cannot cross alone. When a write returns an error, you cannot tell from the client whether the node rejected it (for example, insufficient funds, bad nonce, or a revert) or applied it and lost the response. These two cases require opposite actions: re-send in the first, do not re-send in the second.

The only reliable resolution is to read chain state: check the transaction hash, the account nonce, and the receipt. If the nonce advanced and the receipt exists, it applied. If the nonce is unchanged and no receipt exists after a reasonable wait, it likely did not. Note that 'likely' is doing real work here; there is a window where a transaction is in the mempool but not yet mined, and during that window the outcome is genuinely unknown.

Design for this window explicitly. Persist the signed payload so you can re-broadcast it unchanged, and poll rather than re-sign. Re-signing with a new nonce during the unknown window is what turns an ambiguous outcome into a duplicate.

  • Client alone cannot distinguish rejected from applied-but-lost.
  • Resolve by reading nonce, hash, and receipt.
  • Mempool window is genuinely unknown; poll, do not re-sign.
  • Persist the signed payload to enable safe re-broadcast.

Troubleshooting Duplicate Writes and Next Steps

If you are already seeing duplicates, start by checking whether your retry path re-signs with a new nonce. That is the most common cause. Next, confirm whether your client persists the transaction hash before the send returns; if it only records on success, a timeout loses the hash and forces a guess. Finally, check whether any write is being hedged or batched, both of which widen the failure surface.

For next steps, wire the at-most-once pattern into your client library, add a durable operation store, and add a reconciliation job that scans for operation ids with no recorded hash and resolves them by reading chain state. Then review your read paths against the OnFinality Learn hub for related timeout and nonce guidance, and confirm your endpoint behavior against the RPC endpoints guide (RPC Assistant).

The goal is not to eliminate retries; retries are necessary. The goal is to make every retry safe by ensuring it either re-broadcasts an identical payload or checks chain state first. That is what turns a fragile integration into an at-most-once one.

  • Check for re-signing with a new nonce on retry.
  • Verify the hash is persisted before the send returns.
  • Remove writes from hedged and batched paths.
  • Add a reconciliation job that resolves unknown outcomes by reading chain state.

Never Worry about Infrastructure Again

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

Get Started