Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
RPC Troubleshooting14 min read

Solana sendTransaction Preflight: Error Handling and Safe Retries

A production playbook for Solana sendTransaction preflight: parameter contract, error taxonomy, idempotent retries, and blockhash expiry handling.

TL;DR

Solana's sendTransaction runs a two-phase path: the node first simulates the transaction against a bank selected by preflightCommitment, then forwards it to the cluster. Preflight failures return JSON-RPC error codes such as -32002 (simulation failed), -32003 (signature verification failure), and -32005 (node is behind), which must be branched on separately from transport failures. Because a transaction's signature is derived from its message, resending the byte-identical signed transaction is idempotent: the cluster deduplicates by signature rather than executing twice. A correct submission routine re-queries getSignatureStatuses before each resend, stops on a terminal status, and re-signs with a fresh blockhash once the original expires. This article provides the parameter contract, an error taxonomy, a runnable Node.js retry loop, and a results table to measure against your own endpoint.

The Two-Phase sendTransaction Path and preflightCommitment

The Solana JSON-RPC documentation for sendTransaction describes a two-phase path. In phase one, the receiving node runs a preflight simulation of the transaction against a bank; in phase two, only if preflight succeeds, the node forwards the transaction to the cluster for leader processing. The preflightCommitment parameter selects which bank the simulation runs against, so a transaction simulated at processed may pass locally while failing against the confirmed or finalized bank that the cluster actually uses.

This distinction is the root of a common production bug: a caller sets preflightCommitment: "processed" to reduce latency, sees a successful preflight, and then observes an on-chain failure. The simulation was accurate for the bank it was run against, but that bank was not the one the transaction landed in. For submission paths where correctness matters more than a few milliseconds, align preflightCommitment with the commitment you intend to confirm at, and review Solana commitment levels: processed vs confirmed vs finalized before choosing.

Preflight is a node-side convenience, not a consensus guarantee. It catches obvious failures (missing signatures, insufficient lamports, program errors) before the transaction consumes cluster resources, but it cannot predict state changes that occur between simulation and execution. Treat a passing preflight as a filter, not a promise.

  • Phase 1: node simulates against the bank selected by preflightCommitment.
  • Phase 2: node forwards to the cluster only if phase 1 succeeds.
  • A lower preflightCommitment can pass locally and still fail on the real cluster.
  • Preflight is a filter, not a consensus guarantee.

Inspecting the Raw sendTransaction Error Envelope with curl

Before writing retry logic, it helps to see the exact JSON-RPC error envelope your endpoint returns. The following curl command submits a base64-encoded signed transaction with preflight enabled and prints the raw response. Replace RPC_URL with your endpoint and BASE64_TX with the serialized transaction.

Run it against a deliberately failing transaction (for example, one with insufficient lamports) to observe the -32002 shape, including the data field that carries simulation logs. The same command with a valid transaction shows the success shape: a result field containing the signature string. This is the envelope your client code must parse.

  • Use curl to capture the raw error envelope before coding retries.
  • A failing transaction reveals the -32002 data payload with logs.
  • A valid transaction returns a result string containing the signature.
  • Pipe through jq to inspect nested fields such as data and InstructionError.
curl -s -X POST "$RPC_URL" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "sendTransaction",
    "params": [
      "BASE64_TX",
      {
        "encoding": "base64",
        "skipPreflight": false,
        "preflightCommitment": "confirmed",
        "maxRetries": 0
      }
    ]
  }' | jq .

Production Parameter Contract: encoding, skipPreflight, preflightCommitment, maxRetries

The sendTransaction parameter contract defines four fields that matter in production. encoding controls how the serialized transaction is transmitted (base64 is the common choice for binary-safe transport). skipPreflight bypasses phase one entirely. preflightCommitment selects the simulation bank. maxRetries is a node-side best-effort counter for forwarding the transaction to leaders.

The critical operational point is that maxRetries is documented as a node-side retry, not a delivery guarantee. The node may stop retrying for reasons outside your control (leader schedule, internal queue limits, node restart), and it does not report back to you when it gives up. You must not treat maxRetries as a substitute for your own confirmation loop. Set it to a small value or zero and own the retry logic in your client, where you can observe status.

skipPreflight: true is appropriate only when you have already simulated the transaction yourself (for example via simulateTransaction) and you want to minimize latency, or when you are deliberately racing a known-good transaction. Skipping preflight on an unvalidated transaction means the cluster will reject it after consuming resources, and you will receive a less structured error. The sibling article Decoding Solana simulateTransaction errors covers the diagnostic path when you choose to simulate out-of-band.

  • encoding: base64 is the binary-safe default for serialized transactions.
  • skipPreflight: bypasses simulation; use only after your own simulation.
  • preflightCommitment: selects the bank for phase-one simulation.
  • maxRetries: node-side best-effort, not a delivery guarantee.

Submission-Time Error Taxonomy and Branching Logic

The Solana RPC error-code reference at solana.com/docs/rpc documents the submission-time codes you will encounter. -32002 (Transaction simulation failed) carries a data payload containing the simulation logs and a nested InstructionError; decode that nested error using the method described in Decoding Solana simulateTransaction errors. -32003 (Transaction signature verification failure) means the transaction's signatures do not match the message, which usually indicates a signing bug or a mutated message. -32005 (node is behind) means the node's view of the cluster is stale and you should retry against a different node.

Parameter errors use the standard JSON-RPC -32602 (Invalid params) code, defined by the JSON-RPC 2.0 Specification. These indicate a malformed request (wrong encoding, missing field, invalid base64) and should not be retried without fixing the request. The JSON-RPC 2.0 envelope is the same for all of these: an error object with code, message, and optional data.

Branch on the code, not the message string. The message field is human-readable and may vary by node implementation, but the numeric code is stable. When data is present, it may be a string (simulation logs) or an object (structured error); handle both forms defensively.

  • -32002: Transaction simulation failed; decode nested InstructionError from data.
  • -32003: signature verification failure; fix signing, do not retry blindly.
  • -32005: node is behind; retry against another node.
  • -32602: invalid params; fix the request, do not retry.
  • Branch on numeric code; treat message as human-readable only.

Idempotent Retry: Signature as the Dedupe Key

A Solana transaction's signature is derived from its message. Re-sending the byte-identical signed transaction is therefore safe: the cluster deduplicates by signature and rejects a duplicate as already-known rather than executing it twice. This is the same transport-level property described in JSON-RPC idempotency and duplicate-request safety, applied to the Solana submission path.

The practical consequence is that your retry loop should re-query getSignatureStatuses before each resend. If the signature already has a terminal status (confirmed or finalized), stop. If it is still processed or unknown, resending the identical bytes is safe. Never re-sign the same message with a different blockhash and resend both versions; that produces two distinct signatures and risks double execution.

This is why the retry loop must hold the serialized transaction bytes constant across resends. Any mutation of the message (including a new blockhash) changes the signature and breaks idempotency.

  • Signature is derived from the message; identical bytes produce identical signature.
  • Duplicate submissions are rejected as already-known, not executed twice.
  • Re-query getSignatureStatuses before each resend; stop on terminal status.
  • Never resend two different signatures for the same logical transfer.

Blockhash Lifetime as the Retry Bound

A transaction's recent blockhash is valid only for a limited window. The Solana documentation on transaction confirmation and blockhash lifetime (see solana.com/docs) explains that once the blockhash expires, the transaction can no longer be included, and the cluster will reject it. This bounds your retry loop: you cannot resend a stale transaction forever.

When the blockhash expires, you must re-sign with a fresh blockhash. That produces a new signature, so the idempotency guarantee resets: you must first confirm that the old signature did not land before submitting the new one. The safe sequence is to query getSignatureStatuses for the old signature, and only if it is absent or expired, build and sign a new transaction with a fresh blockhash.

For workflows that cannot tolerate this re-signing window, durable nonces provide a blockhash that does not expire. The tradeoffs are covered in Solana blockhash expiry and durable nonces.

  • Blockhash is valid only for a limited window; expiry bounds retries.
  • On expiry, re-sign with a fresh blockhash; the signature changes.
  • Confirm the old signature did not land before submitting the new one.
  • Durable nonces remove the expiry bound at the cost of extra setup.

Runnable Node.js Submission with Preflight, Error Parsing, and Idempotent Retry

The following example submits with preflight on, parses the JSON-RPC error envelope, falls back to a diagnostic simulateTransaction on -32002, and retries idempotently with backoff until a terminal signature status. It uses @solana/web3.js and a plain fetch for the raw RPC call so the error envelope is visible.

Replace RPC_URL with your endpoint. The loop holds the serialized transaction constant across resends and only re-signs when the blockhash has expired.

import { Connection, Keypair, Transaction, SystemProgram, sendAndConfirmTransaction } from '@solana/web3.js';

const RPC_URL = process.env.RPC_URL; // e.g. your OnFinality Solana endpoint
const connection = new Connection(RPC_URL, 'confirmed');

async function rpc(method, params) {
  const res = await fetch(RPC_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
  });
  const json = await res.json();
  if (json.error) {
    const err = new Error(json.error.message);
    err.code = json.error.code;
    err.data = json.error.data;
    throw err;
  }
  return json.result;
}

async function submitWithRetry(signedTx, maxAttempts = 8) {
  const raw = signedTx.serialize().toString('base64');
  const signature = signedTx.signatures[0].signature.toString('base64');
  let attempt = 0;
  let backoff = 500;

  while (attempt < maxAttempts) {
    attempt++;
    // 1. Check terminal status before resending.
    const statuses = await rpc('getSignatureStatuses', [[signature], { searchTransactionHistory: true }]);
    const status = statuses.value[0];
    if (status && (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized')) {
      return { signature, status: status.confirmationStatus };
    }

    try {
      // 2. Submit with preflight on.
      const sig = await rpc('sendTransaction', [raw, { encoding: 'base64', skipPreflight: false, preflightCommitment: 'confirmed', maxRetries: 0 }]);
      console.log('submitted', sig, 'attempt', attempt);
    } catch (e) {
      if (e.code === -32002) {
        // 3. Diagnostic simulateTransaction fallback.
        const sim = await rpc('simulateTransaction', [raw, { encoding: 'base64', commitment: 'confirmed' }]);
        console.error('preflight failed; simulation logs:', sim.value.logs);
        throw new Error('simulation failed: ' + JSON.stringify(sim.value.err));
      }
      if (e.code === -32003) throw new Error('signature verification failed; fix signing');
      if (e.code === -32005) { /* node behind: fall through to backoff */ }
      if (e.code === -32602) throw new Error('invalid params: ' + e.message);
      // transport or unknown error: back off and retry
    }

    await new Promise(r => setTimeout(r, backoff));
    backoff = Math.min(backoff * 2, 8000);
  }
  throw new Error('exhausted retries without terminal status');
}

// Usage: build, sign, then submit.
const payer = Keypair.generate();
const tx = new Transaction().add(SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: payer.publicKey, lamports: 1 }));
tx.recentBlockhash = (await connection.getLatestBlockhash('confirmed')).blockhash;
tx.feePayer = payer.publicKey;
tx.sign(payer);
submitWithRetry(tx).then(console.log).catch(console.error);

Results Table: Measuring Preflight and Retry Behaviour Against Your Endpoint

Provider behaviour varies, so measure against your own endpoint rather than trusting a generic number. Run the submission loop above against a known-good transaction and record the following fields per attempt. Fill the table with your own observations; do not treat any published figure as a substitute for your own measurement.

The goal is to characterize your endpoint's preflight latency, error distribution, and retry convergence so you can set backoff and attempt limits that match reality.

  • Attempt number
  • sendTransaction wall-clock latency (ms)
  • Error code returned (if any)
  • getSignatureStatuses confirmationStatus at check time
  • Backoff applied before next attempt (ms)
  • Terminal status reached (confirmed/finalized) and total attempts

Failure Modes and Troubleshooting Checklist

Most submission failures fall into a small set of patterns. Work through the checklist in order; each item isolates a different layer of the two-phase path.

If preflight passes but the transaction never confirms, the blockhash likely expired before a leader included it. If preflight fails with -32002, the nested InstructionError tells you which instruction and which program failed; decode it before changing anything else. If you see -32005, the node is behind and you should fail over to another endpoint rather than retrying the same one.

  • Preflight passes, no confirmation: check blockhash expiry and re-sign.
  • -32002: decode nested InstructionError; do not retry unchanged.
  • -32003: verify the message was not mutated after signing.
  • -32005: node is behind; fail over to a healthy endpoint.
  • -32602: fix encoding or parameter shape; do not retry.
  • Duplicate signature rejected: expected; query status instead of resending.
  • Retry loop never terminates: confirm terminal status check is correct.

Tradeoffs and Limitations of Aggressive Retry and skipPreflight

Aggressive retries amplify load on the node and the cluster. Because duplicate submissions are deduplicated by signature, they do not double-execute, but they still consume RPC capacity and can crowd out other callers. Use exponential backoff with a cap, and stop as soon as a terminal status is observed.

skipPreflight: true reduces latency but removes the node-side filter, so malformed or failing transactions reach the cluster and return less structured errors. Reserve it for transactions you have already simulated. Provider-side behaviour also varies: some endpoints apply their own rate limits, queueing, or preflight defaults, so the same parameters may behave differently across providers. Documented behaviour is the baseline; verify against your endpoint.

Finally, maxRetries is a node-side best-effort and should not be relied on for delivery. Own the retry loop in your client where you can observe status and apply backoff. For broader timeout and retry strategy, see Solana RPC timeouts and retry strategy.

  • Retries amplify load; use capped exponential backoff.
  • skipPreflight removes the node-side filter; use only after simulation.
  • Provider-side defaults and rate limits vary; verify against your endpoint.
  • maxRetries is best-effort; own the loop in your client.

Never Worry about Infrastructure Again

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

Get Started