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:
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash("confirmed");
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
processedcommitment) 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
processedblockhash 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
confirmedbut the preflight ran atfinalized, where that block isn't visible yet.
Fixes for the error itself:
- Fetch the blockhash with
confirmedcommitment.processedrisks forks;finalizedis 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 margin
ComputeBudgetProgram.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";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function sendAndConfirmWithRetry(
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) {
throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
}
if (
status?.confirmationStatus === "confirmed" ||
status?.confirmationStatus === "finalized"
) {
return signature;
}
if (height > lastValidBlockHeight) {
throw new Error("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 decides
await sleep(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 keeplastValidBlockHeight. - Match
preflightCommitmentto 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.
Get the weekly commit
New blockchain deep dives every week.