Solana sendTransaction — Best Practices for Production Workloads

How to use sendTransaction correctly on Solana. skipPreflight, maxRetries, blockhash management, priority fees, and the patterns that decide whether your transactions land.

BoltTx Team··9 min read
solanasendtransactionskippreflightblockhashpriority-feerpc

If you've written enough Solana code, you've discovered that sendTransaction is deceptively simple. The signature is short, the parameters look obvious, and the docs imply you can call it with defaults and things will work. In production, things don't work — at least not consistently — and most of the issues come from the same handful of misunderstandings about what sendTransaction actually does.

This piece covers what we've learned about using sendTransaction correctly in production: the parameters that matter, the patterns to use, the patterns to avoid, and how to debug when transactions don't land.

What sendTransaction Actually Does

The naive mental model: "submit a transaction to the network and wait for confirmation."

What actually happens:

  1. Your client serialises the signed transaction.
  2. The RPC receives it, optionally simulates (preflight), and decides whether to forward.
  3. The RPC forwards to the next-scheduled validator.
  4. The validator includes it in a block (or doesn't).
  5. Subsequent blocks confirm the inclusion.

Each of those steps has failure modes. The naive code path that most tutorials show you papers over all of them.

The Parameters That Matter

The relevant options:

connection.sendTransaction(tx, signers, {
  skipPreflight: false,
  maxRetries: 0,
  preflightCommitment: "processed",
});

skipPreflight. When false (default), the RPC simulates the transaction before forwarding. Catches obvious failures early but adds latency. For production sending of pre-validated transactions, set to true — you've already done the validation client-side.

maxRetries. Most production code should set this to 0. The default behaviour retries automatically on the RPC, which often expires your blockhash silently. Manage retries yourself with fresh blockhashes.

preflightCommitment. Only matters when skipPreflight: false. Use "processed" for fast feedback during development; the default is fine for most cases.

The combination most production code wants: skipPreflight: true, maxRetries: 0. You handle retries; you handle simulation client-side; the RPC just delivers.

Blockhash Management

Solana transactions are signed against a specific recent blockhash. The blockhash expires after roughly 60-90 seconds. After expiry, the transaction is unprocessable.

Common mistakes:

Using the same blockhash for multiple retries. If you submit, get no response, and resubmit five seconds later, that's fine. If you've been retrying for 90+ seconds with the same blockhash, you've been submitting expired transactions silently.

Fetching the blockhash too early. Pre-building a transaction with a blockhash and then waiting 60 seconds before signing means the blockhash is already old.

Not handling the "blockhash not found" error. If you simulate against a blockhash that's been recycled out, you get this error. Refetch and rebuild.

Forgetting that processed-commitment blockhashes are less stable. They can be invalidated by short forks. For production, use confirmed-commitment blockhashes for safety, processed-commitment only when you're optimising every millisecond.

The pattern that works:

// Get fresh blockhash, build tx, send — all within a few hundred ms.
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = blockhash;
tx.feePayer = payer.publicKey;

// Pass signers as the second arg; sendTransaction signs internally.
const signature = await connection.sendTransaction(tx, [payer], {
  skipPreflight: true,
  maxRetries: 0,
});

// If you need to retry, build a new transaction with a fresh blockhash.
// Don't reuse the same blockhash.

Priority Fees and Compute Units

Two parameters that decide whether your transaction wins inclusion:

Priority fee. A per-CU on-chain fee that prioritises your transaction in the inclusion queue. Higher means more likely to be included quickly.

Compute unit budget. The maximum CUs your transaction is allowed to consume. Setting this too low fails the transaction; setting it too high wastes priority fee budget.

For production sending:

import { ComputeBudgetProgram, Transaction } from "@solana/web3.js";

const tx = new Transaction()
  .add(ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000 }))
  .add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 100_000 }))
  .add(yourActualInstruction);

Retry Logic Done Right

The naive retry loop:

// Don't do this.
for (let i = 0; i < 5; i++) {
  try {
    const sig = await connection.sendTransaction(tx, signers);
    return sig;
  } catch (e) {
    await sleep(500);
  }
}

Problems:

The pattern that works:

// Better: build fresh transactions for retries, with fresh blockhashes.
async function sendWithRetry(builder, connection, signers, maxAttempts = 2) {
  for (let i = 0; i < maxAttempts; i++) {
    const { blockhash, lastValidBlockHeight } =
      await connection.getLatestBlockhash("confirmed");
    const tx = builder(blockhash);  // builder produces a fresh tx for each attempt
    tx.sign(...signers);

    try {
      const sig = await connection.sendTransaction(tx, signers, {
        skipPreflight: true,
        maxRetries: 0,
      });

      // Confirm against the same blockhash window
      const status = await connection.confirmTransaction(
        { signature: sig, blockhash, lastValidBlockHeight },
        "confirmed"
      );
      if (status.value?.err === null) return sig;
    } catch (e) {
      // Log, decide whether to retry
    }
  }
  throw new Error("Failed after retries");
}

Key principle: never retry past two attempts on the same opportunity. After two attempts, either the opportunity is gone or there's a deeper issue.

Confirmation Strategies

After sendTransaction returns a signature, the transaction may or may not have landed. You need to confirm it:

// Polling approach
const status = await connection.confirmTransaction(signature, "confirmed");

// Or for faster feedback during development:
const status = await connection.confirmTransaction(signature, "processed");

"processed" commitment is fastest but can be reversed by short forks. Fine for development feedback, risky for production financial decisions.

"confirmed" commitment is the production default. Two-thirds of validators have voted on it; effectively final.

"finalized" commitment waits for full finality. Slower but safest. Use for high-value transactions where reorg risk matters.

For latency-sensitive bots, you sometimes want to act on processed (e.g., trigger a downstream order) and reconcile to confirmed later. Don't pretend processed is final, but don't wait for finalized if your strategy can tolerate reorg risk.

Common sendTransaction Failure Modes

Things that go wrong, in rough order of frequency:

Blockhash expired. The transaction was submitted against a stale blockhash. The RPC may or may not surface this — sometimes it silently drops.

Slippage exceeded. For swap transactions, the price moved past tolerance. Transaction fails on-chain. You still pay fees.

Compute unit exceeded. The transaction needed more CU than budgeted. Partial execution, then failure. You pay for the CU consumed.

Priority fee too low. The transaction sat in queue, the blockhash expired before inclusion.

Account not found. Often an ATA that doesn't exist yet. Either pre-create or include creation in the transaction with proper CU budget.

Insufficient balance. Self-explanatory but easy to overlook with priority fees and tips.

Transaction too large. Solana has a 1232-byte limit. Multi-hop swaps and ALT-resolved instructions can hit this.

Nonce / blockhash race. Rare but possible — your transaction was processed but a duplicate also landed. Idempotency at the application layer prevents real damage.

What to Do This Week

If you're improving the reliability of sendTransaction in your codebase:

  1. Set skipPreflight: true, maxRetries: 0 as defaults. Manage retries explicitly.
  2. Audit your retry logic. If you're retrying past two attempts, you have a deeper issue.
  3. Profile CU usage on your real transactions. Set explicit budgets at 1.2-1.5x measured.
  4. Implement profit-aware tipping. Vary the priority fee based on expected value of the transaction.
  5. Build per-signature telemetry. For every transaction you submit, log signature, blockhash, attempt count, latency, outcome. Lets you debug failures.
  6. Use an RPC with delivery telemetry. When a transaction silently fails, "what happened to it" needs to be answerable.

What BoltTx Provides

BoltTx is built for production sendTransaction workloads:

import { Connection } from "@solana/web3.js";

const connection = new Connection(
  "https://bolttx.io/?api-key=YOUR_API_KEY",
  "processed"
);

const signature = await connection.sendTransaction(tx, signers, {
  skipPreflight: true,
  maxRetries: 0,
});

Free tier signup. Run real workload for a week and compare landing rate, P95 latency, and sandwich exposure to your current setup.

FAQ

Should I use skipPreflight: true in production? For pre-validated transactions, yes. It removes a round-trip and small amount of latency. Don't use it if you're not sure your transaction is well-formed.

What's the right maxRetries value? 0 for production. Manage retries yourself with fresh blockhashes.

How long should I wait between retry attempts? Long enough that you're not duplicating in flight (~500ms-2s), short enough that the opportunity hasn't expired. Two attempts max.

What about sendRawTransaction vs sendTransaction? sendRawTransaction is one level lower — you serialise yourself. Slightly faster, almost imperceptibly so. Use whichever your SDK exposes most cleanly.

Why do my transactions land but lose money? Usually sandwich exposure. Compare AMM-math expected output to actual fills. If there's a systematic gap, you're being sandwiched. Anti-MEV RPC routing closes this.

Further Reading

Back to all posts