Your code worked last week. It works fine when you test it. Then the network gets busy and transactions start vanishing — no error, no revert, just a signature that returns null forever.
The problem with debugging this is that all four root causes look identical from your application logs. Every one of them shows up as "I submitted, I got a signature, nothing happened."
This is the order to check them in. Each step is cheap and rules out a whole class of problem, so you find the cause without guessing.
Before You Start: Confirm It Actually Did Not Land
Half the time this whole exercise is unnecessary, because the transaction did land and failed on chain. Those are completely different problems and the fix for one does nothing for the other.
const { value } = await connection.getSignatureStatuses([signature], {
searchTransactionHistory: true,
});
if (!value[0]) {
// Never landed. Continue with this guide.
} else if (value[0].err) {
// Landed and failed. Your problem is on chain: slippage,
// balance, account state, a program error. Stop here and
// read the error instead.
} else {
// Landed and succeeded. Your monitoring is what is broken.
}
If you get a result with an err, stop reading. Your transaction reached the chain, and the fix is in your instructions, not your submission.
Everything below assumes value[0] came back empty.
If you have been through this list already, a free BoltTx key is one line to test the routing side against.
Step 1: Was the Blockhash Still Valid?
This is first because it is the most common cause and the easiest to confirm.
A Solana blockhash is valid for roughly 150 blocks — about 60 seconds under normal conditions. If you submitted after that window, the transaction was invalid the moment it arrived — no fee and no routing could have saved it.
Log the gap:
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("confirmed");
const fetchedAt = Date.now();
// ... build, sign, submit ...
const submittedAt = Date.now();
console.log("blockhash age at submit (ms):", submittedAt - fetchedAt);
console.log("expires at block height:", lastValidBlockHeight);
What you are looking for. If that gap is more than a few seconds, this is your bug. Common causes:
- Fetching one blockhash and reusing it across a batch. The first transactions land, the tail expires.
- A signing step that waits on a user, a hardware wallet, or an external API.
- A retry loop that resends the same signed bytes past the expiry window. Those bytes are permanently dead — resending them will never work.
The fix. Fetch the blockhash as close to signing as you can. If a retry crosses lastValidBlockHeight, build a new transaction with a fresh blockhash rather than resending the old one.
Step 2: Did You Send a Priority Fee?
If step 1 is clean, this is the next most likely answer.
Solana schedules transactions by priority fee when block space is contested. A transaction with no fee is not last in line — during real congestion it is often not in line at all.
import { ComputeBudgetProgram } from "@solana/web3.js";
// Ask the network what recent fees actually looked like, rather
// than hardcoding a number that goes stale.
const recent = await connection.getRecentPrioritizationFees({
lockedWritableAccounts: writableAccountsInYourTx,
});
const fees = recent.map((r) => r.prioritizationFee).sort((a, b) => a - b);
const median = fees[Math.floor(fees.length / 2)] ?? 0;
instructions.unshift(
ComputeBudgetProgram.setComputeUnitPrice({
// Median is a floor, not a target. Multiply for contested writes.
microLamports: Math.max(median * 2, 1_000),
}),
);
What you are looking for. If your fee is zero, or a constant you set months ago, that is the problem.
One thing worth knowing: getRecentPrioritizationFees accepts the writable accounts your transaction touches. Fees are contested per account, not globally. A swap against a hot pool needs a very different fee from a plain transfer, and querying without the account list gives you a number that is not about your transaction at all.
The fix. Derive the fee from recent conditions on the accounts you are writing to. Also set a compute unit limit — without one you are charged against a default that is usually far above what you use, which wastes fee budget:
instructions.unshift(
ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }),
);
Simulate once during development to find your real usage, then set the limit slightly above it.
Step 3: Are You Actually Retrying?
A single submission is a coin flip during congestion. Many teams discover their "retry logic" retries the wrong thing.
// Does not work: same bytes, already expired several attempts ago.
for (let i = 0; i < 5; i++) {
await connection.sendRawTransaction(raw);
await sleep(2000);
}
The loop above is not wrong because it resends — resending identical bytes is safe and correct. It is wrong because it keeps going after the blockhash has expired, and because a 2-second gap wastes most of the window.
// Works: resubmit continuously, stop at expiry, re-sign after.
while (await connection.getBlockHeight("confirmed") <= lastValidBlockHeight) {
const { value } = await connection.getSignatureStatuses([signature]);
if (value[0]) break; // landed, success or failure
await connection.sendRawTransaction(raw, {
skipPreflight: true,
maxRetries: 0, // we drive retries; don't let the RPC also retry
});
await sleep(400); // roughly one slot
}
What you are looking for. Count your actual resubmissions per transaction. If it is 1, that is your problem. If it is 5 spread across 10 seconds, most of those attempts happened after expiry and did nothing.
On maxRetries: 0: the RPC's built-in retry runs on a schedule you cannot see. If you are also retrying, two components are resending on different clocks and the behaviour becomes impossible to reason about. Pick one.
Step 4: Is It Your Submission Path?
Check this last, because it is the only one you cannot fix in application code — and because the first three explain most cases.
The signature that points here: transactions fail during congestion and only during congestion. Same code at 3am works fine. Under load, the landing rate falls off.
That pattern rules out the first three. A blockhash bug fails consistently. A fee that is too low fails whenever fees rise, which you can correlate. A missing retry fails at a steady rate. But a path problem is quiet until block space gets scarce, then it dominates.
Congestion does not slow every transaction down evenly. It widens the spread — most transactions are unaffected while a minority take noticeably longer. That minority is where a weak submission path shows up, and it is also where time-sensitive opportunities live.
Why the path matters here. Validators accept forwarded transactions in proportion to the forwarding node's stake weight. That is what SWQoS means in practice. When block space is plentiful this is invisible. When it is scarce, transactions arriving through a low-stake path get deprioritised exactly when you need them not to be.
The fix. Send through a path built for it. This is the point where changing your endpoint does something that no amount of application-code tuning can.
Quick Reference
| Symptom | Most likely cause | Check |
|---|---|---|
| Fails consistently, any time of day | Blockhash expiry | Log time from fetch to submit |
| Fails when fees rise network-wide | Priority fee too low | Compare your fee to getRecentPrioritizationFees |
| Tail of a batch fails, head succeeds | One blockhash reused across the batch | Fetch per transaction |
| Fails at a steady low rate | Not retrying | Count resubmissions per transaction |
| Only fails under congestion | Submission path | Correlate landing rate with network load |
Returns an err, not null |
Not a landing problem at all | Read the on-chain error |
When It Is Not Your Code
There is a version of this where you do everything right and still lose transactions. Fee derived from live conditions, blockhash fetched at signing, continuous retries until expiry, compute limit set correctly — and the landing rate still drops whenever the network is busy.
At that point the remaining variable is the route between your process and the block producer, and no application-level change will move it.
This is what we build. BoltTx does one thing: get signed transactions into blocks. Not indexing, not parsed history. Submissions go through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so nothing observes your transaction before it lands.
You keep your keys. We never hold funds, never sign, never modify transaction contents. You include a tip in the transaction, paid on chain from your own wallet — and if the transaction reverts, the tip reverts with it, because that is how Solana handles atomic transactions. You pay only on transactions that reach the chain.
Get a free API key. No monthly fee. Switching is one line:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
Run your own comparison for a day before believing any of this. The number that matters is your landing rate during congestion, not anyone's marketing.
FAQ
Why is my Solana transaction not landing? Four causes, in rough order of frequency: the blockhash expired before submission, the priority fee was too low for current congestion, you only submitted once instead of retrying until expiry, or your submission path is deprioritised under load. Check them in that order — the first three are fixable in your own code.
How do I know if my transaction expired or was just dropped?
Log the block height when you fetch the blockhash and compare it to lastValidBlockHeight at your final retry. If you crossed it, the transaction expired and no amount of resending would have helped. If you were still inside the window and it never landed, look at fee and routing.
My transaction returned a signature but I cannot find it on chain. What happened?
The signature is computed locally from the transaction bytes before anything reaches the network, so getting one only means the transaction was well-formed and accepted for forwarding. It never landed. Confirm with getSignatureStatuses rather than treating the signature as proof.
Why do transactions only fail when the network is busy? Because that is when block space is contested and every failure mode compounds. Priority fees spike, the landing window narrows, and any weakness in your submission path stops being invisible. If your code works at 3am and fails during a launch, the cause is fee or routing, not transaction construction.
How much priority fee should I set on Solana?
Derive it from getRecentPrioritizationFees on the writable accounts your transaction touches, rather than hardcoding a value. Fees are contested per account, so a swap against a busy pool needs a very different number from a plain transfer. Treat the recent median as a floor and multiply for contested writes.
Does resending the same transaction risk a double spend? No. Identical signed bytes produce an identical signature, and Solana includes any given signature at most once. Continuous resubmission until the blockhash expires is the standard production pattern.
What is the difference between a dropped transaction and a failed one?
A dropped transaction never reached a block — querying it returns null and it has no slot. A failed transaction reached a block and its instructions errored, so it has a slot and an error. Dropped means a submission problem; failed means a program or market problem.
Should I set a compute unit limit? Yes. Without one you are charged against a default that is usually well above your actual usage, which wastes fee budget for no benefit. Simulate during development to find your real consumption, then set the limit slightly above it.
Why does the tail of my batch fail while the first transactions succeed? Almost always a single blockhash reused across the whole batch. By the time you reach the end, that blockhash is near or past expiry. Fetch per transaction, or refresh partway through a long batch.
What does maxRetries do and why set it to zero?
It controls how many times the RPC resubmits on your behalf. The default is non-zero, so the RPC is retrying on a schedule you do not control. If you are running your own retry loop, set it to 0 so only one component decides when to resend.
How do I measure whether a change actually helped? Track three counters separately: landed and succeeded, landed but failed, never landed. Watch the ratios, not the totals. A single success-rate number mixes submission problems with program problems, so it moves for reasons you cannot attribute.
How long should I wait before deciding a transaction failed?
Until the current block height passes lastValidBlockHeight. Before that it may still land. After that it never will, so waiting longer only delays your retry.
Does the RPC tell me why my transaction did not land? No. There is no rejection message for a transaction that simply was not included. You infer the cause from what you controlled: blockhash age, fee relative to conditions, attempt count, and whether failures cluster under load.
Can I recover a transaction that expired? Not the same transaction. Build a new one with a fresh blockhash and re-sign, which produces a new signature. The expired bytes are permanently dead.
Should I increase my priority fee every time a transaction fails? Only if failures correlate with network-wide fee increases. If they correlate with time of day or specific events instead, the cause is more likely routing or a retry loop that stops too early.