Skip to main content

Comprehensive Guide to Optimizing Solana Transactions

Updated on
Oct 09, 2025

39 min read

Overview

Solana is a high-throughput blockchain, producing blocks roughly every 400ms. This enables the network to achieve some of the highest transactions per second (TPS) of any blockchain today.

In Solana's model, transactions compete for inclusion in blocks. A "failed" transaction is rarely due to network downtime. In fact, Solana has maintained 100% uptime for the past 17 months (as of September 2025). Failures are more commonly caused by:

  • The blockhash sent with the transaction having expired.
  • The transaction using more compute than allowed in its compute budget.
  • A transaction was out-bid by other transactions with a higher priority fee, particularly during times of peak network demand.

In this guide, we'll focus on optimizations to ensure transactions are included in blocks, such as:


  • Priority Fees & Compute Unit Optimization - How to use priority fees provided by QuickNode API and estimate your transaction's compute unit usage
  • Transaction Blockhash Management - Understanding different precommitment values for fetching your blockhash
  • Transaction Retries & Expiration handling - How to retry transaction submission with expiration handling
  • Sending Transaction with QuickNode SDK - Learn how to use SmartTransactions provided by QuickNode SDK
  • Using Stake-Weighted Quality of Service (QoS)
  • Using Jito Bundles

This guide is available in Solana Web3.js (Legacy v 1.x) and Solana Kit (v 3.x) versions. Select the appropriate tab to view the code snippets and instructions for your preferred library:

note

The explanations throughout this guide primarily reference the Solana Web3.js (Legacy) code examples. However, the Solana Kit implementations follow the same conceptual patterns and logic—just with updated syntax and method calls.

Let's get started!

Priority Fees

Solana's fee priority system allows you to set an additional fee on top of the base fee for a transaction, which gives your transaction a higher priority in the leader's queue. By bidding more for priority status, your transaction will be more likely to be confirmed quickly by the network. A higher priority fee does not guarantee your transaction's inclusion in the block, but it does give the transaction priority amongst others being processed in the same thread. Most transactions today utilize priority fees, so ignoring them could risk your transaction getting dropped.

We have a dedicated guide to using priority fees on Solana that explains how they work in detail. However, in this guide, we will focus on how you might determine an appropriate priority fee level for your business requirements.

QuickNode has a Priority fee API, which will fetch the recent priority fees paid across the last (up to) 100 blocks for the entire network or a specific program account. The method qn_estimatePriorityFees returns priority fees in 5% percentiles and convenient ranges (low, medium, high, extreme, and recommended). Here is an example of how you can fetch the latest fees in your TypeScript application:

import { Transaction, ComputeBudgetProgram } from "@solana/web3.js";
import { RequestPayload, ResponseData, EstimatePriorityFeesParams } from "./types";

async function fetchEstimatePriorityFees({
last_n_blocks,
account,
api_version,
endpoint
}: EstimatePriorityFeesParams): Promise<ResponseData> {
const params: any = {};
if (last_n_blocks !== undefined) {
params.last_n_blocks = last_n_blocks;
}
if (account !== undefined) {
params.account = account;
}
if (api_version !== undefined) {
params.api_version = api_version;
}

const payload: RequestPayload = {
method: 'qn_estimatePriorityFees',
params,
id: 1,
jsonrpc: '2.0',
};

const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data: ResponseData = await response.json();
return data;
}

You can add the types to your project by references in our examples repository.

A sample response:

{
"jsonrpc": "2.0",
"result": {
"context": {
"slot": 254387670
},
"per_compute_unit": {
"extreme": 1686993,
"high": 260140,
"low": 10714,
"medium": 50000,
"percentiles": {
"5": 100,
"10": 827,
"15": 2000,
// ...
"90": 510498,
"95": 1686993,
"100": 20000000000,
}
},
"per_transaction": {
"extreme": 4290114,
"high": 500000,
"low": 50000,
"medium": 149999,
"percentiles": {
"5": 700,
"10": 2000,
"15": 9868,
// ...
"90": 1250000,
"95": 4290114,
"100": 20000000000,
}
},
"recommended": 400251
},
"id": 1
}

Priority Fees During High Traffic Periods

During periods of high network congestion, to increase the likelihood of successful transaction processing, we recommend using the recommended property of the response. We monitor the network conditions and adjust the fee level accordingly to maximize the likelihood of successful transaction processing.

You can now select a priority fee level that suits your business requirements. Let's now take a look at compute unit optimization, and then we will discuss how to use both of these methods in your transaction assembly.

Compute Unit Optimization

Every transaction on Solana uses compute units (CU) to process. The more complex the transaction, the more compute units it will consume. The network has a limit on the number of compute units that can be processed in a single block. If your transaction exceeds this limit, it will be dropped.

As of September 2025, the following limits and fee structure are in place:

Compute Unit Limits:

  • Max Compute per block: 60 million CU
  • Max Compute per account per block: 12 million CU
  • Max Compute per transaction: 1.4 million CU
  • Default compute unit limit per transaction: 200,000 CU

Fee Structure:

  • Base transaction fee: 5000 lamports per signature (determinable via getFeeForMessage RPC method)
  • Incremental cost per CU: 0 by default (no prioritization fee unless specified)

Important Notes:

  • The default 200,000 CU limit can be increased up to 1.4 million CU by including a SetComputeUnitLimit instruction
  • Without a SetComputeUnitPrice instruction, you pay no additional fee per compute unit beyond the base transaction fee
  • Priority fees are only applied when you explicitly set a compute unit price using SetComputeUnitPrice
// Example: Setting custom compute limit and price
const transaction = new Transaction()
.add(
// Increase compute limit from default 200,000 to 400,000
ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 })
)
.add(
// Set priority fee (optional - without this, incremental cost per CU is 0)
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 10_000 })
)
.add(...yourInstructions);

Since there has been no history of charging for compute units, there has been little incentive or need to optimize transactions sent to the network. This means that many applications use the default 200,000 CU per transaction or the maximum (to avoid transaction errors). This is not ideal, especially during times of high network traffic, as it can lead to dropped transactions.

Fortunately, you can simulate your transaction before sending it to the cluster to determine the compute units it consumes. This will allow you to send your transaction to the cluster with the least compute units possible, increasing the likelihood of your transaction being included in a block.

To calculate your transaction's compute units, use the simulateTransaction RPC method from the Solana Web3.js library. Here is an example of how you can use this method in your TypeScript application:

async getSimulationUnits(
connection: Connection,
instructions: TransactionInstruction[],
payer: PublicKey
): Promise<number | undefined> {

const testInstructions = [
ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }),
...instructions,
];

const testVersionedTxn = new VersionedTransaction(
new TransactionMessage({
instructions: testInstructions,
payerKey: payer,
recentBlockhash: PublicKey.default.toString(),
}).compileToV0Message()
);

const simulation = await connection.simulateTransaction(testVersionedTxn, {
replaceRecentBlockhash: true,
sigVerify: false,
});

if (simulation.value.err) {
return undefined;
}

return simulation.value.unitsConsumed;
}

A few things to note about our function:

  • Make sure to include a setComputeUnitLimit instruction in your test instructions. This is because our transaction will require this instruction to update the compute unit limit. You can use the max value for the simulation since we are only interested in the number of compute units consumed.
  • If you are using priority fees, make sure to include the priority fee instruction in your instructions array. We will do this in our example below.

Optimized Transaction Assembly

Now that we have tools for fetching recent priority fees and calculating the number of compute units our transaction will consume, we can use these tools to create a transaction assembly that will increase the likelihood of our transaction being included in a block.

Here is an example of how you can use these tools in your TypeScript application:

import { Connection, Keypair, Transaction, ComputeBudgetProgram } from "@solana/web3.js";
import { fetchEstimatePriorityFees, getSimulationUnits } from "./helpers"; // Update with your path to our methods

const endpoint = YOUR_QUICKNODE_ENDPOINT; // Replace with your QuickNode endpoint
const keyPair = Keypair.generate();// derive your keypair from your secret key

async function main(){
// 1. Establish a connection to the Solana cluster
const connection = new Connection(endpoint);

// 2. Create your transaction
const transaction = new Transaction();
// ... add instructions to the transaction

// 3. Fetch the recent priority fees
const { result } = await fetchEstimatePriorityFees({ endpoint });
const priorityFee = result.recommended; // Replace with your priority fee level based on your business requirements, e.g. result.per_compute_unit['high']

// 4. Create a PriorityFee instruction and add it to your transaction
const priorityFeeInstruction = ComputeBudgetProgram.setComputeUnitPrice({
microLamports: priorityFee,
});
transaction.add(priorityFeeInstruction);

// 5. Simulate the transaction and add the compute unit limit instruction to your transaction
let [units, recentBlockhash] =
await Promise.all([
getSimulationUnits(
connection,
transaction.instructions,
keyPair.publicKey

),
connection.getLatestBlockhash(),
]);
if (units) {
units = Math.ceil(units * 1.05); // margin of error
transaction.add(ComputeBudgetProgram.setComputeUnitLimit({ units }));
}

// 6. Sign and send your transaction
transaction.feePayer = keyPair.publicKey;
transaction.recentBlockhash = recentBlockhash.blockhash;
transaction.sign(keyPair);

const hash = await connection.sendRawTransaction(
transaction.serialize(),
{ skipPreflight: true, maxRetries: 0 }
);

return hash;
}

Let's break down the steps in our example:

  1. We establish a connection to the Solana cluster.
  2. We create our transaction. This is where you would add your instructions to the transaction.
  3. We fetch the recent priority fees using our fetchEstimatePriorityFees method.
  4. We create a priority fee instruction and add it to our transaction. You can customize your priority fee level based on your business requirements.
  5. We simulate the transaction and add the compute unit limit instruction to our transaction. We use the getSimulationUnits method to calculate the number of compute units our transaction will consume. We also fetch a recent blockhash for our transaction.
  6. We sign and send our transaction to the Solana cluster.

And like that, you now have a transaction optimized for priority fees and compute units. This will increase the likelihood of your transaction being included in a block! Feel free to modify the functions to suit your needs.

Blockhash Management & Expiration Logic

Proper blockhash management is critical to figuring out what type of transaction you are crafting. The blockhash determines when your transaction expires and affects RPC node behavior during simulation and submission.

Commitment Levels

Each commitment level provides different guarantees about transaction finality:

PropertyProcessedConfirmedFinalized
Received blockXXX
Block on majority forkXXX
Block contains target txXXX
66%+ stake voted on block-XX
31+ confirmed blocks built atop block--X

Source: Anza Docs - Commitments

// Recommended approach for most applications
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');

Tracking Transaction Expiration

Understanding how lastValidBlockHeight works is crucial for proper transaction management. When you fetch a blockhash, Solana provides both the blockhash and lastValidBlockHeight (the maximum block height at which your transaction will still be valid).

How Blockhash Expiration Works

Solana validators maintain a BlockHashQueue containing the 300 most recent blockhashes. However, transactions are only valid if their blockhash is within the most recent 151 stored hashes. This creates the expiration window:

  • Window Size: 151 blocks from lastValidBlockHeight
  • Duration: Approximately 60-90 seconds (depending on slot timing of ~400-600ms per slot)
// When you fetch a blockhash, you can utilize the following values
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');

console.log('Current block height:', await connection.getBlockHeight('confirmed'));
console.log('Transaction expires at block height:', lastValidBlockHeight);
console.log('Expiration window:', lastValidBlockHeight - await connection.getBlockHeight('confirmed'), 'blocks');

Transaction Rebroadcasting Strategies

RPC nodes have generic retry algorithms, but they're often insufficient for application-specific needs. During network congestion, implementing custom retry logic dramatically improves success rates.

Understanding RPC Default Behavior

By default, RPC nodes will:

  • Rebroadcast transactions every 2 seconds
  • Continue until the transaction is finalized or the blockhash expires
  • Drop transactions if the queue exceeds 10,000 pending transactions

This generic approach doesn't account for your application's specific requirements or current network conditions.

Manipulating maxRetries Field

During high congestion, set maxRetries = 0 and implement your own rebroadcasting algorithm:

// Set maxRetries = 0 for manual control
const signature = await connection.sendRawTransaction(
transaction.serialize(),
{
skipPreflight: false,
maxRetries: 0
}
);

A Retry Pattern Code Example

Constant Interval Rebroadcasting

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

import { sha512 } from "@noble/hashes/sha2.js";

import * as ed from "@noble/ed25519";

import { setTimeout as delay } from "timers/promises";

ed.hashes.sha512 = sha512;

const BUFFER = 10;

const { secretKey, publicKey } = await ed.keygenAsync();
const secretKey64 = new Uint8Array(64);
secretKey64.set(secretKey, 0);
secretKey64.set(publicKey, 32);

const payer = Keypair.fromSecretKey(secretKey64);
const toAccount = Keypair.generate().publicKey;

console.log("💧 Airdropping 1 SOL to payer...");
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const airdropSignature = await connection.requestAirdrop(
payer.publicKey,
LAMPORTS_PER_SOL,
);
await connection.confirmTransaction(
{ signature: airdropSignature },
"confirmed",
);

const blockhashResponse = await connection.getLatestBlockhash();
const lastValidBlockHeight = blockhashResponse.lastValidBlockHeight - BUFFER;

const transaction = new Transaction({
feePayer: payer.publicKey,
blockhash: blockhashResponse.blockhash,
lastValidBlockHeight,
}).add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: toAccount,
lamports: 1_000_000,
}),
);

const message = transaction.serializeMessage();

const signature = ed.sign(message, secretKey);

transaction.addSignature(payer.publicKey, Buffer.from(signature));

const rawTransaction = transaction.serialize();

let blockheight = await connection.getBlockHeight();
let attempts = 0;
let landed = false;

// Retry loop
while (blockheight < lastValidBlockHeight && !landed) {
attempts++;
console.log(
`\n📤 Attempt #${attempts} — current block: ${blockheight}, cutoff: ${lastValidBlockHeight}`,
);

try {
const signature = await connection.sendRawTransaction(rawTransaction, {
skipPreflight: true,
maxRetries: 0,
});
console.log(`📝 Sent tx, signature: ${signature}`);

// Poll for confirmation after sending
const status = await connection.getSignatureStatus(signature, {
searchTransactionHistory: false,
});

console.log("🔎 Status check:", status.value);

if (
status.value?.confirmationStatus === "confirmed" ||
status.value?.confirmationStatus === "finalized"
) {
console.log(`✅ Transaction confirmed after ${attempts} attempt(s)!`);
landed = true;
break;
}
} catch (error) {
console.error("Error sending transaction:", error);
}

await delay(500);
blockheight = await connection.getBlockHeight();
}

if (!landed) {
console.warn("Transaction not confirmed before blockhash expired.");
}

Let's break down this retry pattern implementation step by step:

1. Setup and Dependencies

import { setTimeout as delay } from "timers/promises";
const BUFFER = 10;

const { secretKey, publicKey } = await ed.keygenAsync();
const secretKey64 = new Uint8Array(64);
secretKey64.set(secretKey, 0);
secretKey64.set(publicKey, 32);
const payer = Keypair.fromSecretKey(secretKey64);

We define a delay utility and a buffer to reduce the expiration window slightly, accounting for network delays. We also convert Noble's key generation result to be compatible with Solana's Keypair object, which requires a 64-byte Uint8Array containing both the 32-byte secret key and 32-byte public key.

2. Transaction Preparation

const blockhashResponse = await connection.getLatestBlockhash();
const lastValidBlockHeight = blockhashResponse.lastValidBlockHeight - BUFFER;

We fetch a fresh blockhash and reduce the lastValidBlockHeight by our buffer to ensure we stop retrying before the actual expiration.

3. Transaction Construction and Signing

const transaction = new Transaction({
feePayer: payer.publicKey,
blockhash: blockhashResponse.blockhash,
lastValidBlockHeight,
}).add(/* instructions */);

const message = transaction.serializeMessage();
const signature = ed.sign(message, secretKey);
transaction.addSignature(payer.publicKey, Buffer.from(signature));

We build the transaction with our blockhash and manually sign it. Pre-signing allows us to reuse the same transaction bytes for multiple submission attempts.

4. Retry Loop with Expiration Checking

while (blockheight < lastValidBlockHeight && !landed) {
// Continue retrying while transaction is still valid and not confirmed
}

The loop continues as long as:

  • Current block height is below our expiration threshold (lastValidBlockHeight - BUFFER)
  • Transaction hasn't been confirmed yet

5. Transaction Submission with Error Handling

const signature = await connection.sendRawTransaction(rawTransaction, {
skipPreflight: true, // Skip simulation to avoid delays
maxRetries: 0 // Disable retry - we handle it manually
});

We use skipPreflight: true and maxRetries: 0 for maximum control over the retry process.

6. Immediate Confirmation Checking

const status = await connection.getSignatureStatus(signature, {
searchTransactionHistory: false // Faster lookup, recent transactions only
});

After sending, we immediately check if the transaction was confirmed rather than waiting for the next retry cycle.

7. Controlled Retry Timing

await delay(500);  // 500ms between attempts
blockheight = await connection.getBlockHeight();

We wait 500ms between attempts (faster than the default 2-second RPC retry) and update our block height for the next iteration.

Key Benefits of This Pattern:

  • Fast retry cycles: 500ms intervals vs default 2-second RPC retry
  • Manual control: Full visibility into retry attempts and timing
  • Early termination: Stops immediately when transaction confirms
  • Safety buffer: Accounts for network processing delays

Use Stake-Weighted Quality of Service (SWQoS)

Stake-Weighted Quality of Service (SWQoS) is a newer mechanism to increase the likelihood of your transaction being included in a block. QuickNode provides a variety of SWQoS options:


  • Using Smart Transactions (or manually building the transaction following best practices) with a priority fee API setting of recommended or higher will use stake from QuickNode's own stake pool, and do the proper assembly of compute and priority fees for the user. Any user sending non-duplicate transactions with recommended priority fees or higher will now be routed through QuickNode's own stake pool.

  • Using Jito bundles, i.e. (Lil' JIT's sendTransaction or sendBundle methods) will use Jito's own stake pool, which has significant stake in the Solana network.

  • For ultra-high priority transactions, including professional traders and high-frequency automated trading, Fastlane, enabled in the QuickNode marketplace, is a SWQoS option using a stake pool from the top 1% largest validators in Solana, and is the absolute best way to increase your transaction landing rate.

QuickNode SDK

If you want to streamline the process of assembling Solana transactions, you can use the QuickNode SDK to create and send optimized transactions! Note: you will need the Solana Priority Fees Add-on to use the relevant methods in the SDK.

To use the SDK, you will need to install it in your project:

npm i @quicknode/sdk # or yarn add @quicknode/sdk

There are three relevant methods in the SDK that you can use to create and send optimized transactions:

  • sendSmartTransaction - This method will create and send a transaction with priority fees and optimized compute units and a given Keypair.
  • prepareSmartTransaction - This method will prepare a transaction with priority fees and optimized compute units.
  • fetchEstimatePriorityFees - This method will fetch the recent priority fees using the qn_estimatePriorityFees add-on method.

Here's a sample of how you can use the SDK to send a "smart" transaction to the Solana cluster:

import { solanaWeb3, Solana } from "@quicknode/sdk";
const { Transaction, SystemProgram, Keypair, PublicKey } = solanaWeb3;

const mainSecretKey = Uint8Array.from([
// Replace with your secret key
]);
const sender = Keypair.fromSecretKey(mainSecretKey);
const receiver = new PublicKey("YOUR_RECEIVER_ADDRESS");
const senderPublicKey = sender.publicKey;

const endpoint = new Solana({
endpointUrl:
"https://some-cool-name.solana-mainnet.quiknode.pro/redacted",
});

const transaction = new Transaction();

// Add instructions for each receiver
transaction.add(
SystemProgram.transfer({
fromPubkey: senderPublicKey,
toPubkey: receiver,
lamports: 10,
})
);

(async () => {
// Endpoint must added to Priority Fee API to do this
const signature = await endpoint.sendSmartTransaction({
transaction,
keyPair: sender,
feeLevel: "recommended"
});
console.log(signature);
})().catch(console.error);

In this example, we use the sendSmartTransaction method to create and send a transaction with priority fees and optimized compute units. We also use the SystemProgram.transfer instruction to transfer lamports from the sender to the receiver. You can customize the feeLevel based on your business requirements. That's it!

Wrap Up

We've covered how to optimize a Solana transaction using priority fees, compute optimization, proper blockhash management, and custom rebroadcasting strategies to achieve high transaction success rates. By combining these techniques with tools like the QuickNode SDK, you can increase the likelihood of your transaction being included in a block.

Check out QuickNode Marketplace to integrate our Solana Priority Fees Add-on and explore other tools to improve your business operations.

If you have a question or idea you want to share, drop us a line on Discord or X (Formerly Twitter)!

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide