A Solana transaction usually fails to land for one of three reasons: its blockhash expired, its priority fee was too low for the accounts it touches, or the RPC node dropped it before a leader saw it. The fix is a loop, not a single call. Fetch a fresh blockhash at finalized and keep its lastValidBlockHeight. Set the compute unit limit from a simulation and the compute unit price from recent fees for your accounts. Rebroadcast the same signed bytes until the block height passes lastValidBlockHeight. Only then sign again with a new blockhash.
Two things changed on mainnet in October 2026 and most guides have not caught up. Slot time dropped to 200 ms on 9 October, so a blockhash now lives about 30 seconds. And on 21 October the first phase of Alpenglow (Votor) went live, so confirmed and finalized now describe the same state. Both are covered below, with code that type-checks under tsc --strict and ran on 23 October 2026 against Solana’s public endpoints.
For the basics (what an endpoint is, the method list, the commitment table) read the Solana RPC guide. This article is about the one thing that guide does not cover: getting a transaction into a block.
Why Solana transactions do not land
sendTransaction returns a signature as soon as the node accepts the bytes. Solana’s docs say it plainly: a successful response “does not guarantee the transaction is processed or confirmed by the cluster”. There is no mempool. The node forwards the transaction to the current and next leaders over UDP, which “does not provide any guarantees regarding transaction delivery”. So the three causes look like this:
- The blockhash expired. The transaction was valid for 150 blocks and none of your sends reached a leader in time. See the next section.
- The fee was too low for contested accounts. Solana’s fee page ranks transactions by reward divided by estimated compute cost, so what counts is the fee per unit of compute, not the fee alone. If you write to an account that many other transactions write to, a fee that is fine for a quiet account can lose every slot.
- The node dropped it. Under load a node’s rebroadcast queue can fill (the cookbook gives a cap of 10,000 transactions). Solana’s retry cookbook also describes a node pool where one backend is behind the others: it does not recognise a blockhash that another backend issued and drops the transaction.
The common bug behind all three is treating one successful sendTransaction as the end. It is the start of a wait that you have to watch.
Blockhash expiry: 150 blocks, now about 30 seconds
Every transaction carries a recent blockhash, and the validator accepts it only while that hash is within 150 blocks of the tip. getLatestBlockhash returns the hash together with lastValidBlockHeight, the last block height at which it still works. On mainnet we measured lastValidBlockHeight as the current block height plus 150.
The count is the stable part. The time is not. At 400 ms slots 150 blocks was about 60 seconds. Solana’s reduced slot times page says: “Solana’s slot time is now 200ms, down from 400ms” and “With 200ms blocks, blockhash expiration is now 30 seconds.” On 22 October 2026 we measured about 212 ms per slot on the public endpoint (283, 277 and 282 slots in three 60 second samples), which puts 150 blocks at roughly 32 seconds. Older guides, including Solana’s own confirmation guide, still say 60 to 90 seconds because they predate the change.
So never put a time in your logic. Compare block heights:
- Fetch the blockhash right before signing, not at page load. A signing flow that waits on a person now has half the slack it had.
- Store
lastValidBlockHeightnext to the transaction. - Treat the transaction as dead only when
getBlockHeightreturns a number greater thanlastValidBlockHeight.
Which commitment for the blockhash
Until 21 October 2026 the usual advice was confirmed, because finalized trailed confirmed by at least 32 slots and a finalized blockhash started with less of its lifetime left. The first phase of Alpenglow, Votor, removed that gap when it went live on mainnet that day. Solana’s Alpenglow page says “confirmed and finalized now describe the same state” and “Point new code at finalized”, and that confirmed will be deprecated and removed in a later release (no date given). It lists the second phase, Rotor, as not yet scheduled. The code below uses finalized everywhere.
If you read the endpoint yourself, this is the call. Use your own key:
curl https://endpoints.boltrpc.io/rpc/YOUR_API_KEY/solana \
-X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":1,"method":"getLatestBlockhash","params":[{"commitment":"finalized"}]}'
The response holds value.blockhash and value.lastValidBlockHeight. We ran this against the BoltRPC Solana endpoint on 23 October 2026 and it answered with status 200.
Set compute units and priority fee
The fee has two parts. The base fee is 5,000 lamports per signature. The priority fee is the compute unit price times the compute unit limit, divided by 1,000,000 and rounded up, in lamports. We checked the formula against getFeeForMessage on mainnet:
- 300,000 units at price 0 gives 5,000 lamports.
- 300,000 units at 10,000 micro-lamports per unit gives 8,000 lamports (5,000 plus 3,000).
- 300,000 units at 1,000,000 micro-lamports per unit gives 305,000 lamports.
The priority fee is charged on the limit you request, not on what the transaction uses. A limit of 1,400,000 (the per-transaction cap) on a simple transfer means paying for compute you never touch. The compute budget page says to simulate and add a 10% margin.
One caveat: the version 1 transaction format ignores ComputeBudget instructions and sets the limits in the message config instead. The code here builds version 0 transactions, which use the instructions.
Size the limit from a simulation
simulateTransaction returns unitsConsumed. Use replaceRecentBlockhash and skip signature checks and you need neither a fresh blockhash nor a key to measure it. The payer account must still exist and hold SOL: an unfunded payer returns AccountNotFound. A SOL transfer with the two compute budget instructions measured 450 units, 496 after the 10% margin.
The code is for @solana/web3.js 3.x, where blockhashes are a branded string and maxRetries and block heights are bigint.
import {
ComputeBudgetProgram, Connection, Keypair, PublicKey,
TransactionInstruction, TransactionMessage, VersionedTransaction,
type BlockhashWithExpiryBlockHeight,
} from "@solana/web3.js";
// web3.js 3.x brands the blockhash string; derive the type instead of importing it.
type Blockhash = BlockhashWithExpiryBlockHeight["blockhash"];
const PLACEHOLDER_BLOCKHASH = "11111111111111111111111111111111" as Blockhash;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Compile a v0 transaction with the compute budget instructions first. */
export function compile(
payer: PublicKey,
blockhash: Blockhash,
instructions: TransactionInstruction[],
units: number,
microLamports: number,
): VersionedTransaction {
const message = new TransactionMessage({
payerKey: payer,
recentBlockhash: blockhash,
instructions: [
ComputeBudgetProgram.setComputeUnitLimit({ units }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports }),
...instructions,
],
}).compileToV0Message();
return new VersionedTransaction(message);
}
/** Measure compute units by simulation: the node swaps in its own blockhash
* and signatures are not checked. The payer account must exist on chain. */
export async function estimateComputeUnits(
connection: Connection,
payer: PublicKey,
instructions: TransactionInstruction[],
) {
const probe = compile(payer, PLACEHOLDER_BLOCKHASH, instructions, 1_400_000, 0);
const sim = await connection.simulateTransaction(probe, {
replaceRecentBlockhash: true,
sigVerify: false,
commitment: "finalized",
});
if (sim.value.err) {
throw new Error("Simulation failed: " + JSON.stringify(sim.value.err));
}
const used = Number(sim.value.unitsConsumed ?? 200_000); // bigint in web3.js 3.x
return { used, limit: Math.min(1_400_000, Math.ceil(used * 1.1)) }; // 10% margin
}
Price it from recent fees for your accounts
getRecentPrioritizationFees returns one value per slot, in micro-lamports per compute unit, over up to the last 150 blocks. It is only useful when you pass the writable accounts your transaction touches. With no accounts, all 150 values were 0 when we called it on mainnet on 22 and 23 October 2026. On 23 October at about 01:54 UTC we called it for the Raydium SOL/USDC AMM pool, account 58oQChx4yWmvKd11111111111111111111111111111112) all 150 were zero. The Agave source on master (read on 22 October) returns the lowest fee seen per slot, so these are floors, not bids (see the Agave source).
Take a percentile of the window, never the maximum. Single slots can spike far above the typical value. On 23 October 2026 the window maximum across eight Jito tip accounts ran from 40,006 to 1,882,483 micro-lamports per compute unit, while most slots on those accounts were zero. Paying a window maximum like that would have cost far more than the transaction needed. The 75th percentile is our own design choice, not an official recommendation. Raise it for time-critical sends.
/** Price from recent per-slot values for the accounts the transaction writes to. */
export async function pickMicroLamports(
connection: Connection,
writable: PublicKey[],
percentile = 0.75,
): Promise<number> {
const samples = await connection.getRecentPrioritizationFees({
lockedWritableAccounts: writable,
});
const fees = samples.map((s) => Number(s.prioritizationFee)).sort((a, b) => a - b);
if (fees.length === 0) return 0;
return fees[Math.min(fees.length - 1, Math.floor(percentile * fees.length))];
}
With a 10,000 micro-lamport price and a 300,000 unit limit the priority fee is 3,000 lamports. A price that is too high is a real cost, so keep the limit tight.
Send and rebroadcast until lastValidBlockHeight
sendTransaction has four options worth setting on purpose:
skipPreflight(defaultfalse). Preflight checks the signatures and simulates the transaction before the node accepts it, so a bad blockhash or an unfunded payer fails fast with a readable error. Solana’s retry cookbook recommends keeping itfalse. Its production page allowstruefor callers who have already validated the transaction and want the lowest latency. Treat that as a trade-off, not a default.preflightCommitment. Set it to the commitment you used for the blockhash. It is ignored whenskipPreflightistrue, and the node then usesprocessedinternally.maxRetries. Left unset, the node retries until the transaction is finalized or the blockhash expires. SetmaxRetries: 0and the retrying is yours, which lets you decide the interval and know when to stop.minContextSlot. The minimum slot at which preflight checks run. ThegetLatestBlockhashresponse carries a context slot, but web3.jsgetLatestBlockhashdrops it, so usegetLatestBlockhashAndContextas the code below does and passcontext.slothere. A node that has not reached that slot answersMinimum context slot has not been reachedinstead of checking against older state.
The loop below sends once with preflight on, then rebroadcasts the same signed bytes every 2 seconds. Resending identical bytes is safe because the signature does not change. It stops when getBlockHeight passes lastValidBlockHeight, and takes a last look at the signature history in case the transaction landed in the final block.
type SendLoopOptions = {
resendMs?: number;
skipPreflight?: boolean;
minContextSlot?: bigint;
};
/** Send once, then rebroadcast the same bytes until it lands or the blockhash is dead. */
export async function sendUntilExpiry(
connection: Connection,
raw: Uint8Array,
lastValidBlockHeight: bigint,
{ resendMs = 2000, skipPreflight = false, minContextSlot }: SendLoopOptions = {},
): Promise<{ landed: boolean; sig: string }> {
const sig = await connection.sendRawTransaction(raw, {
maxRetries: 0n, // we do the retrying, not the node (a bigint in web3.js 3.x)
skipPreflight,
preflightCommitment: "finalized", // same level the blockhash was fetched at
minContextSlot,
});
while (true) {
const st = (await connection.getSignatureStatuses([sig])).value[0];
if (st?.err) throw new Error("Landed but failed: " + JSON.stringify(st.err));
if (st?.confirmationStatus === "confirmed" || st?.confirmationStatus === "finalized") {
return { landed: true, sig };
}
if ((await connection.getBlockHeight("finalized")) > lastValidBlockHeight) {
// Last look: it may have landed in the final block before expiry.
const last = (await connection.getSignatureStatuses([sig], { searchTransactionHistory: true })).value[0];
return { landed: !!last && !last.err && !!last.confirmationStatus, sig };
}
await connection.sendRawTransaction(raw, { maxRetries: 0n, skipPreflight: true });
await sleep(resendMs);
}
}
Accepting both confirmed and finalized as a landed status costs nothing and keeps the loop working if a node still reports the older word.
The outer function ties it together. Each attempt gets its own blockhash. It only signs again after sendUntilExpiry has returned, which means the old blockhash is dead. Signing a second copy while the first blockhash is still valid risks both being accepted.
export async function landTransaction(
connection: Connection,
payer: Keypair, // must be a funded account, or the simulation fails with AccountNotFound
instructions: TransactionInstruction[],
writable: PublicKey[],
{ maxAttempts = 3 } = {},
): Promise<string> {
const cu = await estimateComputeUnits(connection, payer.publicKey, instructions);
const microLamports = await pickMicroLamports(connection, writable);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Fresh blockhash with its expiry height and the slot it was read at.
const { context, value } = await connection.getLatestBlockhashAndContext("finalized");
const tx = compile(payer.publicKey, value.blockhash, instructions, cu.limit, microLamports);
await tx.sign([payer]); // async in web3.js 3.x
const result = await sendUntilExpiry(connection, tx.serialize(), value.lastValidBlockHeight, {
minContextSlot: context.slot,
});
if (result.landed) return result.sig;
}
throw new Error("Not landed after " + maxAttempts + " blockhashes");
}
What we ran, with versions: @solana/web3.js 3.1.1 on Node 24.19.0, with TypeScript 7.0.2 and tsx. All four code blocks pass tsc --strict. On 23 October 2026 at about 01:53 UTC we ran them against Solana’s public mainnet and devnet endpoints. On mainnet the estimate gave 450 units (496 with the margin) and getFeeForMessage matched the fee formula. landTransaction needs a funded payer: with a fresh unfunded keypair it throws Simulation failed: "AccountNotFound" from the estimate step. For the expiry test we skipped that and called sendUntilExpiry directly on devnet with an unfunded keypair and skipPreflight: true. The loop rebroadcast about every 2 seconds and stopped when the finalized block height passed lastValidBlockHeight, 38.9 seconds after the start including polling overshoot. A control send with a minContextSlot far in the future was refused with Minimum context slot has not been reached. We could not fund a key, so the branch where the transaction actually lands was not exercised on-chain. Test it on your own funded devnet account before you rely on it.
@solana/kit 8.5.0 has the same building blocks: setTransactionMessageComputeUnitLimit, setTransactionMessageComputeUnitPrice and a sendAndConfirmTransactionFactory that throws SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED when the blockhash expires. We ran Kit only to build and simulate a message, not to send and confirm.
What the loop costs on BoltRPC
Each call is billed at its published weight. sendTransaction is 5 request units, getSignatureStatuses 3 and getBlockHeight 3, so one 2 second tick is 11 units and a full 30 second window is about 15 ticks. signatureSubscribe over the WebSocket replaces the status polling if you would rather be pushed the result. The weights are in how request units work.
Errors and what they mean
Transaction simulation failed, code-32002. Preflight rejected the transaction. The cause is indata.erranddata.logs, not in the message. We sent a transaction with a made-up blockhash and gotdata.err: "BlockhashNotFound". As a control, the same unfunded payer with a real blockhash gaveAccountNotFound, so the code tells you nothing until you readdata.err.Blockhash not found. This is web3.js 3.x client text (on 1.98.4 the same error printsTransaction simulation failed). The node itself sendsBlockhashNotFoundinside the-32002error. If the blockhash is young and you still see it, suspect a node pool where the backend that issued the hash is ahead of the one you sent to, as the retry cookbook describes. Send to the same endpoint you fetched from, or setminContextSlot.isBlockhashValidreturnstrueorfalsefor a given hash if you want to check.block height exceeded. web3.js raisesSignature ... has expired: block height exceededwhen the block height passeslastValidBlockHeight. It is a client-side error, not a node response. In Kit it isSOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED. It means the blockhash is dead and you can re-sign.- HTTP 429,
Connection rate limits exceeded. The public endpoint documents 40 requests per 10 seconds per IP for a single method. We sent 60 parallelgetBlockHeightcalls and got 40 answers and 20 429s, with aRetry-After: 10header. Back off for that long. A 2 second tick with three calls sits well under the limit, but a bot that runs the loop for many transactions at once does not. See RPC rate limits explained.
Durable nonces
A durable nonce replaces the recent blockhash with a stored nonce value, so the transaction has no 150 block expiry. It suits offline signing and delayed submission. You build the transaction with the nonce advance instruction first and the nonce value as the blockhash, and you refetch the nonce account before building the next one. Solana’s page warns that durable nonces “may be deprecated in a future release”, with a SIMD discussion open. We did not run this flow, so treat it as documented behaviour and prefer the loop above for live submissions.
FAQ
How long is a Solana blockhash valid?
150 blocks. At the current 200 ms slot time that is about 30 seconds, down from about 60 seconds at 400 ms. Older guides saying 60 to 90 seconds predate the slot time change. Use lastValidBlockHeight from getLatestBlockhash instead of a clock.
Does a successful sendTransaction mean my transaction landed?
No. It means the node accepted the bytes. You still have to poll getSignatureStatuses or subscribe to the signature, and watch the block height against lastValidBlockHeight.
Should I set skipPreflight to true?
Keep it false unless you simulate the transaction yourself and want the lowest latency. Preflight catches an expired blockhash or a bad account before the transaction goes out. With skipPreflight: true, preflightCommitment is ignored.
What priority fee should I set?
Call getRecentPrioritizationFees with the writable accounts your transaction touches and take a percentile of the window, such as the 75th. Do not take the maximum, since one slot can carry an extreme outlier. Then multiply by a tight compute unit limit to see the fee.
When is it safe to sign a transaction again?
Only after its blockhash has expired, meaning the block height is above lastValidBlockHeight. While the first blockhash is valid, both copies can be accepted. Resending the same signed bytes is always safe.
Run it on a dedicated endpoint
The landing loop makes a steady stream of small calls, so a rate-limited public endpoint is the wrong place to run it. The BoltRPC Solana endpoint is https://endpoints.boltrpc.io/rpc/YOUR_API_KEY/solana, with WebSocket at wss://endpoints.boltrpc.io/ws/YOUR_API_KEY/solana. We checked getLatestBlockhash, getRecentPrioritizationFees and isBlockhashValid against it on 23 October 2026 and all three answered with status 200. It does not change how a leader orders your transaction, so the fee and the blockhash discipline above still do the landing. See the Solana network page for the supported methods and pricing for plans.
