How do I fix Solana "Blockhash not found" and dropped transactions? · Jumpstart Blockchain
How do I fix Solana "Blockhash not found" and dropped transactions?
Fetch the blockhash at confirmed commitment right before signing, add a priority fee, and rebroadcast the same signed bytes until confirmed or past lastValidBlockHeight; only then re-sign.
Transaction simulation failed: Blockhash not found
TransactionExpiredBlockheightExceededError: Signature ... has expired: block height exceeded.
Both errors come from the same design decision. A Solana transaction doesn't carry a nonce. Instead it carries a recent blockhash, and that blockhash is what stops the transaction being replayed. It's also why transactions expire, and why a transaction that was "sent" can quietly never land.
How recent blockhashes work
When you build a transaction you include recentBlockhash. A validator only processes it if that blockhash is among the last 150 blocks it knows about. After that the transaction is permanently invalid and can never be executed. That is roughly a minute or so of wall-clock time, depending on slot times.
getLatestBlockhash returns two things, and you need both:
lastValidBlockHeight is the last block height at which a transaction using this blockhash can still be included. Once the chain's block height passes it, your transaction is dead. That's the only reliable signal that it's safe to build and sign a new one.
Why "Blockhash not found" happens
This error usually comes from preflight simulation on the RPC node, not from the network itself. Common causes:
The blockhash is too new for the node that simulated it. You fetched it from one RPC node (or with processed commitment) and sent it to another node that hasn't seen that block yet. Load-balanced RPC endpoints make this easy to hit.
The blockhash came from a fork that was dropped. A processed blockhash can belong to a block that never gets confirmed. Transactions referencing it can never land.
The blockhash is too old. You fetched it once, then signed later: after a wallet prompt, a hardware wallet confirmation, or a slow queue.
Commitment mismatch. You fetched the blockhash at but the preflight ran at , where that block isn't visible yet.
The priority fee is CU limit × CU price in micro-lamports, on top of 5,000 lamports per signature. Simulate to size the limit, price from recent fees on the accounts you write, and never request 1.4M by default.
Fetch the blockhash with confirmed commitment. processed risks forks; finalized is safe but already uses up a chunk of the 150-block window.
Use the same commitment for preflight: preflightCommitment: "confirmed".
Fetch the blockhash as late as possible: right before signing, not when the user opens the form.
Pin fetch and send to the same RPC endpoint when you can.
Setting skipPreflight: true makes the error message go away, but it doesn't make an expired or forked blockhash valid. It only removes your early warning.
Why transactions get dropped
sendTransaction returning a signature means the RPC node accepted it, not that the network did. The RPC forwards your transaction to upcoming leaders. Under load, leaders drop packets and transactions that pay too little to get scheduled. The RPC node retries in the background, but its retry policy is generic, and if the transaction still hasn't landed when the blockhash expires, it's gone. No error is recorded on-chain because nothing ever happened on-chain.
To get transactions to land:
Add a priority fee and set a realistic compute unit limit. The priority fee is charged on the limit you request, so simulate first and request close to what you use.
Rebroadcast yourself until the transaction confirms or its blockhash expires, instead of relying on the RPC's default retries.
Use an RPC provider with good transaction forwarding. Providers with staked connections (stake-weighted QoS) generally get transactions to leaders more reliably under congestion.
import { ComputeBudgetProgram } from"@solana/web3.js";
tx.add(
ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }), // from simulation, plus a marginComputeBudgetProgram.setComputeUnitPrice({ microLamports: priorityFee }),
);
Base priorityFee on connection.getRecentPrioritizationFees() for the writable accounts your transaction touches, not on a hard-coded constant.
A send loop that handles expiry correctly
The key rule: rebroadcasting the same signed transaction is always safe, because the network deduplicates by signature. Re-signing with a new blockhash is only safe after the old one has expired. If you re-sign early, both versions can land and your transfer runs twice.
import { Connection, Keypair, Transaction } from"@solana/web3.js";
constsleep = (ms: number) => newPromise((r) =>setTimeout(r, ms));
exportasyncfunctionsendAndConfirmWithRetry(connection: Connection,
tx: Transaction,
payer: Keypair,
): Promise<string> {
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = blockhash;
tx.feePayer = payer.publicKey;
tx.sign(payer);
const raw = tx.serialize();
const signature = await connection.sendRawTransaction(raw, {
preflightCommitment: "confirmed",
maxRetries: 0, // we handle retries ourselves
});
while (true) {
// Read the height first, then the status, so a late landing isn't missed.const height = await connection.getBlockHeight("confirmed");
const { value } = await connection.getSignatureStatuses([signature]);
const status = value[0];
if (status?.err) {
thrownewError(`Transaction failed: ${JSON.stringify(status.err)}`);
}
if (
status?.confirmationStatus === "confirmed" ||
status?.confirmationStatus === "finalized"
) {
return signature;
}
if (height > lastValidBlockHeight) {
thrownewError("Blockhash expired; the transaction can no longer land. Safe to rebuild and re-sign.");
}
await connection
.sendRawTransaction(raw, { skipPreflight: true, maxRetries: 0 })
.catch(() => {}); // ignore transient send errors; the status check decidesawaitsleep(2000);
}
}
If you use connection.confirmTransaction, pass the full strategy object { signature, blockhash, lastValidBlockHeight } rather than the deprecated signature-only form. It throws TransactionExpiredBlockheightExceededError when the height passes, which is the same "safe to retry" signal as above.
When 150 blocks isn't long enough
Offline signing, multisig approval flows and hardware wallets that sit waiting for a human can easily exceed the window. For those, use a durable nonce: a nonce account whose stored value replaces the recent blockhash, with SystemProgram.nonceAdvance as the first instruction. The transaction then stays valid until the nonce is advanced, and advancing it invalidates any other transaction signed with the same nonce.
Checklist
Fetch the blockhash with confirmed, as late as possible, and keep lastValidBlockHeight.
Match preflightCommitment to the commitment you fetched with; avoid mixing RPC nodes between fetch and send.
Add a priority fee and a simulated compute unit limit.
Rebroadcast the same signed bytes until confirmed or until block height exceeds lastValidBlockHeight.
Only then rebuild with a fresh blockhash and re-sign.
Use durable nonces for anything that waits on a human or an offline signer.
Some account would end the transaction with more than zero lamports but less than its rent-exempt minimum. Find it by index, then fund it fully, sweep it to zero, or fund your reallocs.