Why your Solana transactions are not landing
A transaction that never lands failed at one of six specific points: blockhash expiry, an underpriced fee, compute budget, RPC forwarding, leader connection limits, or account contention. This walks through each one, how to tell them apart from the outside, and what actually fixes each.
- A transaction that never lands has failed before execution. It is a delivery problem, and it is invisible on chain.
- There are six realistic causes: blockhash expiry, an underpriced fee, compute budget, RPC forwarding, leader connection limits, and account contention.
- You can tell most of them apart from outside, but only if you record the conditions at submission time. Landing rate alone is not actionable.
- Fix them in order of cost: blockhash freshness is free, fee pricing is cheap, delivery path is the one that needs infrastructure.
There is a particular kind of frustration that comes from a transaction that simply vanishes. It is not that it failed. A failed transaction is on chain, it has a signature you can look up, it has an error you can read, and it charged you a fee for the privilege of telling you what went wrong. That is a good day.
The bad day is the one where you submit, get a signature back, and then nothing. No confirmation, no error, no record. Four minutes later you query the signature and the RPC tells you it has never heard of it. The transaction did not fail. It never existed as far as the chain is concerned.
This happens for a small number of specific reasons, and the reasons are separable if you instrument for them. This post is about telling them apart.
The six places a transaction dies#
Between your process calling send and a validator writing your transaction into a block, there are six points where it can be discarded. Roughly in order of how far it got:
| Failure point | What happened | Visible on chain? |
|---|---|---|
| Blockhash expiry | Your blockhash aged past 150 slots before a leader processed it | No |
| Underpriced fee | The leader had better-paying transactions and dropped yours | No |
| Compute budget | Your transaction exceeded its requested units mid-execution | Yes, and you paid |
| RPC forwarding | Your RPC accepted it, queued it, and never got it to the leader | No |
| Leader connection limits | The leader refused the connection carrying it | No |
| Account contention | The write lock you needed was held, repeatedly, until you expired | No |
Notice that five of the six leave no trace. That is the entire difficulty. You are debugging something that, by construction, produced no evidence.
Not landed is not the same as failed#
Before anything else, be precise about which problem you have, because the fixes have nothing in common.
- Landed and failed. The transaction is in a block. It has a signature, an error, and a fee you paid. Slippage exceeded, insufficient funds, a program assertion. This is an application problem.
- Never landed. There is no record. This is a delivery problem, and everything in this post is about this case.
The distinction is one RPC call, and getting the call wrong is the most common mistake in this whole area.
1import { Connection } from "@solana/web3.js";23const rpc = new Connection(process.env.RPC_URL!, "confirmed");45/**6 * The only honest way to ask "did it land?".7 *8 * searchTransactionHistory matters: without it the RPC only checks its recent9 * status cache, which is a few hundred slots deep. A transaction that landed10 * four minutes ago comes back null and you conclude it was dropped when it11 * was not.12 */13async function didItLand(signature: string) {14 const { value } = await rpc.getSignatureStatuses([signature], {15 searchTransactionHistory: true,16 });17 const status = value[0];1819 if (status === null) return { landed: false, reason: "not seen by this RPC" };20 if (status.err) return { landed: true, reason: "landed and failed on chain", err: status.err };21 return { landed: true, reason: status.confirmationStatus };22}
searchTransactionHistory: true is not optional. Without it the RPC only consults its recent status cache, which is a few hundred slots deep. A transaction that landed three minutes ago returns null, you record it as dropped, and your entire landing-rate metric is wrong in the pessimistic direction. I have watched teams re-architect a sender to fix a problem that was this flag.Diagnosing from outside#
You cannot see inside a leader. What you can do is record the conditions of every submission and then correlate. The failure points have different signatures in the data:
| If losses correlate with… | Suspect |
|---|---|
| Low remaining blockhash window at send time | Blockhash expiry |
| Your fee being below the recent clearing price for that account | Underpricing |
| Specific hot accounts, regardless of fee | Contention |
| Time of day, or bursts of network activity | Connection limits or RPC forwarding |
| Nothing at all, and losses are steady | Your delivery path |
That last row is the uncomfortable one. A steady, condition-independent loss rate usually means the problem is not your transaction, it is how your transaction reaches the leader.
1. Blockhash expiry#
Every Solana transaction references a recent blockhash, and that blockhash is valid for 150 slots. At roughly 400ms per slot that is about 60 seconds, and in practice less, because slots are sometimes skipped and because the clock started before you finished building.
The window is smaller than you think
Count the spend honestly:
- Fetching at
finalizedcommitment hands you a hash that is already about 32 slots old. - Building, signing and serializing costs a few hundred milliseconds if you are not careful.
- Your network hop to an RPC, and its hop to the leader, cost more.
- If you retry, every retry is spending the same window, not a fresh one.
Start at finalized and you can be a third of the way through the budget before the transaction has left your process.
1import { Connection } from "@solana/web3.js";23const rpc = new Connection(process.env.RPC_URL!, "confirmed");45/**6 * How much of the blockhash window you have left, in slots.7 *8 * A blockhash is valid for 150 slots. getLatestBlockhash hands you9 * lastValidBlockHeight, and the difference between that and the current10 * block height is your remaining budget. Note that block height and slot11 * are not the same number: slots can be skipped, block height cannot.12 */13async function remainingWindow() {14 const { blockhash, lastValidBlockHeight } = await rpc.getLatestBlockhash("confirmed");15 const currentHeight = await rpc.getBlockHeight("confirmed");1617 return {18 blockhash,19 slotsLeft: lastValidBlockHeight - currentHeight,20 approxSecondsLeft: (lastValidBlockHeight - currentHeight) * 0.4,21 };22}2324// A blockhash fetched at "finalized" is already ~32 slots old before you25// start. That is a fifth of the window spent on nothing.26const { slotsLeft } = await remainingWindow();27if (slotsLeft < 100) {28 console.warn("stale blockhash, fetch a newer one before building");29}
The fix
- Fetch blockhashes at
confirmed, notfinalized, for anything latency-sensitive. - Keep a background refresher that pulls a new blockhash every few seconds and hands the freshest one to your builder, so you never pay the fetch on the critical path.
- Do not retry the same signed bytes for a minute. Rebuild with a fresh blockhash instead. Read the blockhash lifecycle for the full mechanics.
Where a deadline genuinely does not suit you, a durable nonce removes it entirely at the cost of serializing that nonce account.
1import { NonceAccount, SystemProgram, Keypair, Connection, PublicKey } from "@solana/web3.js";23/**4 * A durable nonce replaces the recent blockhash with a value that does not5 * expire until you advance it yourself.6 *7 * This trades a hard 60-to-90 second deadline for a serialization point: one8 * nonce account can have exactly one transaction in flight at a time. That is9 * the right trade for a scheduled or offline-signed transaction, and the wrong10 * one for a bot firing continuously, which should keep a pool of nonce11 * accounts or simply keep its blockhash fresh.12 */13async function buildWithNonce(rpc: Connection, noncePubkey: PublicKey, authority: Keypair) {14 const info = await rpc.getAccountInfo(noncePubkey);15 if (!info) throw new Error("nonce account not found");16 const nonceAccount = NonceAccount.fromAccountData(info.data);1718 return {19 // The advance instruction MUST be first in the transaction.20 advance: SystemProgram.nonceAdvance({21 noncePubkey,22 authorizedPubkey: authority.publicKey,23 }),24 // Use the stored nonce where a recent blockhash would normally go.25 recentBlockhash: nonceAccount.nonce,26 };27}
2. An underpriced fee#
A priority fee is a bid, denominated in micro-lamports per compute unit. Two things about that sentence trip people up constantly: it is micro-lamports, and it is per compute unit. Your actual priority fee is
requested compute units × price per unit ÷ 1,000,000 = lamports
So a bid of 10,000 micro-lamports on a transaction requesting 200,000 units costs 2,000 lamports. Get the units wrong and you have mispriced by the same factor.
Contention is per account, not global
This is the part most fee logic gets wrong. Solana schedules on account write locks, so the competition for a block slot is competition for the specific accounts you write to. The network-wide median fee is close to meaningless when you are trying to write to one hot pool that fifty other bots also want.
getRecentPrioritizationFees takes the accounts you care about for exactly this reason. Use it.
1import { ComputeBudgetProgram, Connection, PublicKey } from "@solana/web3.js";23const rpc = new Connection(process.env.RPC_URL!, "confirmed");45/**6 * Price a priority fee from what is actually clearing, not from a constant.7 *8 * getRecentPrioritizationFees returns what recent blocks charged for9 * transactions touching the accounts you name. Passing the accounts your10 * transaction writes to is the whole point: contention is per-account, and11 * the network-wide median tells you nothing about the one hot AMM pool you12 * are competing for.13 */14async function priceFee(writableAccounts: PublicKey[], percentile = 0.75) {15 const samples = await rpc.getRecentPrioritizationFees({16 lockedWritableAccounts: writableAccounts,17 });1819 const fees = samples20 .map((s) => s.prioritizationFee)21 .filter((f) => f > 0)22 .sort((a, b) => a - b);2324 if (fees.length === 0) return 1_000; // nothing contended recently2526 const index = Math.min(fees.length - 1, Math.floor(fees.length * percentile));27 return Math.max(1_000, fees[index]);28}2930// microLamports per compute unit, NOT lamports, and NOT per transaction.31const microLamportsPerCu = await priceFee([POOL_ACCOUNT, USER_TOKEN_ACCOUNT]);3233const instructions = [34 ComputeBudgetProgram.setComputeUnitLimit({ units: 120_000 }),35 ComputeBudgetProgram.setComputeUnitPrice({ microLamports: microLamportsPerCu }),36 ...yourInstructions,37];3839// What you will actually pay in priority fee:40// 120_000 CU x microLamportsPerCu / 1_000_000 = lamports
3. Compute budget#
This is the one failure on the list that does land. Your transaction reaches a block, starts executing, exceeds the compute units it asked for, and aborts. You are charged. It shows up as an on-chain error, so strictly it is not a delivery problem, but it belongs here because people routinely misfile it as one.
The default is 200,000 units per instruction, capped at 1.4 million per transaction. The instinct is to request the maximum and stop thinking about it. That instinct is wrong, and expensively so: the scheduler treats your requested limit as your cost, so an inflated request makes you look expensive to schedule and multiplies your priority fee.
Measure instead.
1import { Connection, VersionedTransaction } from "@solana/web3.js";23/**4 * Measure what your transaction really consumes instead of guessing.5 *6 * Simulation reports unitsConsumed. Set the limit slightly above the measured7 * figure, not at the 1.4M maximum: the scheduler treats the requested limit as8 * the cost of the transaction, so an inflated request makes you look expensive9 * to schedule and makes your priority fee bid more expensive than it needs10 * to be.11 */12async function measureUnits(rpc: Connection, tx: VersionedTransaction) {13 const sim = await rpc.simulateTransaction(tx, {14 replaceRecentBlockhash: true,15 sigVerify: false,16 });1718 if (sim.value.err) {19 throw new Error(`simulation failed: ${JSON.stringify(sim.value.err)}`);20 }21 return sim.value.unitsConsumed ?? 0;22}2324const consumed = await measureUnits(rpc, tx);25const limit = Math.ceil(consumed * 1.15); // headroom for account state drift26console.log(`measured ${consumed} CU, requesting ${limit}`);
Add ten to twenty percent headroom over the measured figure, because account state drifts between simulation and execution and a swap that touches one more tick array is a real thing that happens. Details in compute units explained.
4. RPC forwarding#
When you call sendTransaction on a general-purpose RPC, that RPC accepts your bytes and takes responsibility for getting them to the current and upcoming leaders. What happens next is entirely implementation-defined, invisible to you, and shared with every other customer of that provider.
Concretely, your transaction is now queued behind everyone else’s, and:
- the provider decides how many leaders to forward to, and how often to retry;
- the provider’s connection to the leader is shared across its whole customer base;
- under load, the provider sheds traffic, and it does not tell you which traffic;
- you got a signature back the moment your bytes were accepted, which told you nothing about delivery.
This is the failure point that produces the steady, condition-independent loss rate from the diagnosis table. Nothing about your transaction is wrong. It is queueing behind strangers.
5. Leader connection limits#
A Solana leader accepts transactions over QUIC, and it cannot accept unbounded connections. Capacity is allocated by stake: validators with more stake get proportionally more of the leader’s connection budget, and unstaked traffic competes for a small remainder.
This is stake-weighted quality of service, and it is the mechanism that decides whose packets even get read during contention. A transaction arriving over a staked connection is competing in a much smaller queue than one arriving through a public endpoint. When the network is busy, that difference stops being an optimisation and starts being the whole game.
The full mechanics are in stake-weighted QoS explained.
6. Account contention#
Solana executes transactions in parallel when they do not touch the same accounts, and serializes them when they do. Every transaction declares which accounts it reads and which it writes. Two transactions writing the same account cannot run in the same batch.
So when fifty bots all want to write to the same AMM pool in the same slot, forty-nine of them are waiting regardless of what they paid. Fee helps you win the ordering. It does not create parallelism that the account graph does not allow.
Practical consequences:
- Do not mark accounts writable that you only read. It is free contention you invented.
- Splitting one contended account into several is an application design decision with real throughput implications.
- If a specific account is your bottleneck, no amount of delivery engineering fixes it. That is a protocol-level constraint.
A diagnostic procedure#
Do this in order. Each step is cheap and rules out a whole class of cause, so running them out of order wastes the most expensive investigation on the least likely explanation.
- Confirm you actually have a delivery problem. Re-check every signature with
searchTransactionHistory: true. A surprising share of reported delivery problems are a missing flag. - Record conditions at send time. Remaining blockhash window, fee bid, requested compute units, the accounts written. Without this you are guessing.
- Split losses by remaining window. If the stale bucket is materially worse, you have a blockhash problem and it is free to fix.
- Compare your bid against the recent clearing price for your accounts. If you are below it, that is your answer. If you are comfortably above it, stop tuning the fee.
- Check your on-chain failures separately. Compute budget errors are hiding in there and they are not delivery.
- What remains is the path. If the loss rate is flat across every condition you control, the transaction is fine and the delivery is not.
Here is the instrumentation the middle steps depend on.
1type Attempt = {2 signature: string;3 sentAt: number;4 blockhashSlotsLeft: number;5 feeMicroLamports: number;6 requestedCu: number;7};89/**10 * Record the state of every submission at the moment you send it.11 *12 * Almost every "my transactions do not land" investigation stalls because13 * nobody wrote down what the conditions were. Landing rate on its own is a14 * number you cannot act on. Landing rate split by remaining blockhash window,15 * by fee percentile and by requested compute units tells you which of the six16 * failure points is actually yours.17 */18const attempts = new Map<string, Attempt>();1920export function record(a: Attempt) {21 attempts.set(a.signature, a);22}2324export async function reconcile(rpc: Connection) {25 const sigs = [...attempts.keys()];26 const rows: Record<string, number> = {};2728 // getSignatureStatuses takes up to 256 signatures per call.29 for (let i = 0; i < sigs.length; i += 256) {30 const batch = sigs.slice(i, i + 256);31 const { value } = await rpc.getSignatureStatuses(batch, {32 searchTransactionHistory: true,33 });3435 batch.forEach((sig, j) => {36 const a = attempts.get(sig)!;37 const s = value[j];38 const bucket =39 s === null ? "never_seen"40 : s.err ? "landed_failed"41 : "landed_ok";4243 // The bucket alone is not the finding. The correlation is.44 const window = a.blockhashSlotsLeft > 100 ? "fresh" : "stale";45 const key = `${bucket}:${window}`;46 rows[key] = (rows[key] ?? 0) + 1;47 });48 }49 return rows;50}
A fuller treatment, including how to turn this into an ongoing number rather than a one-off investigation, is in measuring your real landing rate.
What to fix, in order#
Cheapest and most certain first:
- Blockhash freshness. Costs nothing, fixed in an afternoon, and it is a real cause more often than anyone expects. Background refresher,
confirmedcommitment, rebuild rather than replay. - Compute limits. Measure and set them. Stop requesting 1.4 million units for a transfer.
- Fee pricing. Price from live per-account data at a percentile you choose deliberately, not from a constant somebody picked in a hurry six months ago.
- Account layout. Only if contention is your measured bottleneck. This is an application change and it is not cheap.
- Delivery path. Once the transaction itself is correct, the remaining losses are the route. Getting off a shared public queue and onto a staked connection is the fix, and it is the one that requires infrastructure rather than a code change.
The order matters because the first three are free and rule out most of the field. Rebuilding your delivery infrastructure to solve what turns out to be a stale blockhash is a bad week.