Blockhash Not Found on Solana: How to Retry Correctly
What Solana's Blockhash not found and block height exceeded errors mean, why they happen, and a retry loop that never sends a trade twice. Node.js code.
Two Solana errors send developers looking for the same fix. Transaction simulation failed: Blockhash not found comes back the moment you send. Signature … has expired: block height exceeded comes back from web3.js after a long wait. Both are about the transaction's recent blockhash, but they mean different things, and the wrong retry for either can make one trade land twice.
Below: what each error means, how long a blockhash lasts, the usual causes, and a retry loop that resends safely, with Node.js code we ran on a local test validator.
The short answer
- Blockhash not found when you send: the node that checked the transaction doesn't know its blockhash, and nothing went out. Fetch the blockhash and send on one node, at one commitment, never
processed. - Block height exceeded: web3.js didn't see the transaction confirm within about 150 blocks of its blockhash. If
getSignatureStatusesdoesn't show it, it never will, so sign again with a fresh blockhash. - Retrying: resend the same signed bytes until it lands or expires, and only then sign a new copy.
Two errors, two meanings
| You see | Where it comes from | What it means | Did it go out? |
|---|---|---|---|
Transaction simulation failed: Blockhash not found | The RPC node's preflight check (JSON-RPC error -32002) | The bank the node simulated against doesn't have your blockhash | No: a node that fails preflight doesn't forward it |
TransactionExpiredBlockheightExceededError ("block height exceeded") | web3.js confirmTransaction | The block height passed lastValidBlockHeight before web3.js saw the signature confirm | Maybe. If it hasn't landed by now, it won't |
This transaction has already been processed | Preflight on a resend | These exact bytes have already landed | Yes, once |
This is how the first two look in @solana/web3.js 1.x, from our test run (signature shortened):
Simulation failed.
Message: Transaction simulation failed: Blockhash not found.
Logs:
[].
Catch the `SendTransactionError` and call `getLogs()` on it for full details.
TransactionExpiredBlockheightExceededError: Signature 3GFKDjH5… has expired: block height exceeded.How long a blockhash lasts
Every transaction carries a recent blockhash, and that blockhash sets its expiry.
- 150 blocks of age. Validators record one blockhash per block and keep the last 300. Unless it uses a durable nonce, a transaction is accepted only if its blockhash is among the last 151, a maximum age of 150.
- About 30 seconds. About 150 blocks is about 30 seconds, with a slot of about 200 ms. The Solana Foundation puts it the same way: still 150 blocks, now 30 seconds.
lastValidBlockHeight.getLatestBlockhashreturns it with the blockhash: the height of the block the blockhash came from, plus 150. OncegetBlockHeightat your commitment returns a higher number, no new block will accept the transaction.- Count blocks, not seconds. Block height is the number of blocks beneath the current one, and a skipped slot adds none, so 30 seconds is an average. Polling the block height is exact.
Why transactions expire
Each validator remembers the transactions it has processed, keyed by message hash, for as long as their blockhash could still be valid, and rejects a repeat as already processed. Solana keeps no per-account counter like Ethereum's nonce; the short validity window is what keeps that memory small. Two rules follow, and safe retries rest on both:
- Sending the same signed bytes again is safe. It's the same transaction, so it lands at most once.
- Signing again with a new blockhash makes a new transaction. While the old one is still valid, both can land.
Why "Blockhash not found" happens
With preflight on, the RPC node checks your transaction against its own bank at preflightCommitment before forwarding it. The error means that bank doesn't have your blockhash. These are the usual reasons.
The blockhash came from a node further ahead
If your endpoint is a pool of nodes behind one URL, getLatestBlockhash can be answered by a node a block or two ahead and sendTransaction by one behind that hasn't seen the blockhash yet; Solana's retry guide describes this case. Use one node for the blockhash, the preflight and the status checks, or resend the same bytes a moment later. A node whose health check says it is behind refuses with Node is behind by N slots (error -32005) instead.
The commitments don't match
The node simulates at preflightCommitment, which defaults to finalized; web3.js sends the Connection's commitment if you gave it one. Fetch a blockhash at processed, send with the defaults, and the node checks it against an older bank. On our local validator, where finalized trailed processed by 31 slots:
slots now: processed 559, confirmed 559, finalized 528
default preflight (finalized): Message: Transaction simulation failed: Blockhash not found.
same bytes, preflightCommitment processed: accepted and landed (2jYG8745)On mainnet since Alpenglow, confirmed and finalized describe the same state, so the gap between those two has closed (see our Alpenglow guide), but processed still runs ahead. Solana's advice stands: always set preflightCommitment to the commitment you fetched the blockhash at.
The blockhash came from a dropped block
processed means a block this node has executed, not one the cluster has voted on. If that block is abandoned, its blockhash never becomes valid on the chain that carries on, and per Solana's guide a transaction using it "won't ever be considered recent by any blocks that are in the finalized blockchain." Fetch at confirmed or finalized.
The blockhash was stale before you sent
A blockhash cached at startup, a wallet prompt left open, a signing queue: once the blockhash is more than 150 blocks old, preflight gives the same error, as our expiry test showed. Fetch the blockhash right before signing, polling in the background so a fresh one is always ready. If the wait can run past 30 seconds, use a durable nonce (below).
The blockhash is from another cluster
A devnet blockhash sent to mainnet, or a made-up one in a test, is one the node has never seen. Our test with 32 random bytes as the blockhash:
JSON-RPC error: {"code":-32002,"message":"Transaction simulation failed: Blockhash not found","err":"BlockhashNotFound"}
isBlockhashValid: falseisBlockhashValid checks a blockhash without sending anything.
Why "block height exceeded" happens
In web3.js 1.x, confirmTransaction({ signature, blockhash, lastValidBlockHeight }) waits for a WebSocket notification for the signature while polling getBlockHeight once a second. If the height passes lastValidBlockHeight first, it throws TransactionExpiredBlockheightExceededError. The usual reasons:
- No leader took it. Packets were lost or throttled on the way, or a busy leader scheduled other transactions first. See why transactions arrive late even with priority fees.
- Nobody resent it. By default, an Agave RPC node resends to the next two leaders every 2 seconds until the transaction is finalized or expires, unless you set
maxRetries. WithmaxRetries: 0it sends once, and the retrying is yours. A node whose retry queue is full (10,000 transactions by default) sends yours once and doesn't retry it. - It started late. Every second it waited before the first send came off its 30 seconds.
- The WebSocket wasn't working. If the notification never arrives, web3.js reports expiry even when the transaction landed. We tested it: with the WebSocket unreachable, a transfer that landed and reached
finalizedstill came back ashas expired: block height exceeded.
So read the error as "not seen confirmed in time". Check the signature once with getSignatureStatuses, and sign again only if it isn't there.
The retry pattern
- Fetch a blockhash and
lastValidBlockHeightatconfirmed(orfinalized, the same state on mainnet now), neverprocessed. - Sign once and keep the serialized bytes.
- Send with preflight on,
preflightCommitmentset to the same level andmaxRetries: 0, to the endpoint you fetched from. - Resend the same bytes every couple of seconds with
skipPreflight: true(a copy that already landed would fail preflight), while you pollgetSignatureStatuses. - Stop when the signature is
confirmedorfinalized, or whengetBlockHeightat your commitment is abovelastValidBlockHeight. - Only then rebuild with a fresh blockhash, re-quote if it's a swap, and sign again.
Don't sign a second copy early
This is what happens if you do. We signed a transfer and held it back, as if stuck in a queue, then signed the same transfer with a newer blockhash and sent it, and finally delivered the first:
copy B (new blockhash) landed: 5Vv4rbgc
copy A (old blockhash, still valid) landed too: 4yEtyEuu
recipient received 2000000 lamports for one intended transfer of 1000000
resending copy A's bytes: Message: Transaction simulation failed: This transaction has already been processed.Two copies with different blockhashes are two transactions, and both were valid. For a swap, that's two buys. Solana's retry guide warns of the same: while the first blockhash is still valid, "it is possible for both transactions to be accepted by the network." Resending copy A's own bytes, by contrast, was refused.
When signing again straight away is safe
After a preflight failure. The node answered with an error before queueing the transaction, so it went nowhere, and a fresh blockhash is safe, as long as you didn't also send those bytes another way (a second RPC, a relay). A timeout or a dropped connection is not a refusal: the bytes may have gone out, so keep resending them.
Code: sendWithRetry in Node.js
The script fetches a blockhash at confirmed, signs once, sends with preflight and maxRetries: 0, resends the same bytes every 2 seconds while it polls the status, and signs again only after the block height passes lastValidBlockHeight, up to three attempts. It never throws while a signed copy could still land. The keypair path and the RPC URL come from environment variables.
// send-with-retry.mjs: send a Solana transaction and retry it without sending it twice.
// Sign once, resend the same bytes until it lands or its blockhash expires,
// and only then sign again with a fresh blockhash.
// npm i @solana/web3.js@1 bs58 (Node 18 or later)
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import bs58 from "bs58";
import {
Connection,
Keypair,
PublicKey,
SendTransactionError,
SystemProgram,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
// One commitment for the blockhash, the preflight and the expiry check.
const COMMITMENT = "confirmed";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// build() returns the instructions. It runs again before each new signature,
// so a swap can re-quote. Returns the signature that landed.
export async function sendWithRetry(connection, payer, build, { maxAttempts = 3, resendMs = 2_000, log = console.log } = {}) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// 1. A blockhash, and the last block height at which it is still valid.
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(COMMITMENT);
// 2. Sign once. These exact bytes are what gets resent.
const message = new TransactionMessage({
payerKey: payer.publicKey,
recentBlockhash: blockhash,
instructions: await build(),
}).compileToV0Message();
const tx = new VersionedTransaction(message);
tx.sign([payer]);
const raw = tx.serialize();
const signature = bs58.encode(tx.signatures[0]);
log(`attempt ${attempt}: signed ${signature.slice(0, 8)}, valid until block height ${lastValidBlockHeight}`);
// 3. First send: preflight on, at the blockhash's commitment.
// maxRetries 0: the node sends it once and leaves the retrying to us.
try {
await connection.sendRawTransaction(raw, { skipPreflight: false, preflightCommitment: COMMITMENT, maxRetries: 0 });
} catch (err) {
if (!(err instanceof SendTransactionError)) {
// No answer (timeout, network): it may have gone out, so keep these bytes.
log(`first send: ${err.message}; carrying on with the same bytes`);
} else if (/blockhash not found/i.test(err.message)) {
// The node refused it, so it went nowhere: a fresh blockhash is safe.
log("preflight: Blockhash not found; signing again with a fresh blockhash");
await sleep(500);
continue;
} else {
throw err; // Funds, accounts, program logic: retrying will not fix these.
}
}
// 4. Resend the same bytes until it lands or its blockhash expires.
let sends = 1;
for (;;) {
await sleep(resendMs);
let height, status;
try {
// Height first, then status, so a late landing is never taken for expiry.
height = await connection.getBlockHeight(COMMITMENT);
[status] = (await connection.getSignatureStatuses([signature])).value;
} catch (err) {
log(`status check: ${err.message}`);
continue; // Unknown is not expired: keep checking.
}
if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized") {
if (status.err) throw new Error(`${signature} landed but failed: ${JSON.stringify(status.err)}`);
log(`landed in slot ${status.slot} after ${sends} send(s)`);
return signature;
}
if (height > lastValidBlockHeight) {
log(`expired: block height ${height} > ${lastValidBlockHeight}, after ${sends} send(s)`);
break; // 5. Only now is it safe to sign again with a fresh blockhash.
}
sends++;
await connection
.sendRawTransaction(raw, { skipPreflight: true, maxRetries: 0 })
.catch((err) => log(`resend: ${err.message}`));
}
}
// Every signed copy has expired, so none of them can land now.
throw new Error(`not landed after ${maxAttempts} attempts`);
}
// Example: send LAMPORTS (default 1,000) to TO (default: yourself).
if (process.argv[1] === fileURLToPath(import.meta.url)) {
if (!process.env.KEYPAIR) throw new Error("Set KEYPAIR to the path of a keypair JSON file");
const connection = new Connection(process.env.RPC_URL ?? "http://127.0.0.1:8899", COMMITMENT);
const payer = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(fs.readFileSync(process.env.KEYPAIR, "utf8"))));
const to = new PublicKey(process.env.TO ?? payer.publicKey);
const lamports = Number(process.env.LAMPORTS ?? 1_000);
const signature = await sendWithRetry(connection, payer, () => [
SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: to, lamports }),
]);
console.log(`done: ${signature}`);
}Set KEYPAIR to a keypair file and RPC_URL to your RPC; without RPC_URL it talks to a local validator.
- One endpoint. The blockhash, the preflight, the resends and the status checks all go through one
connection. If your provider pools nodes behind one URL, pin a single node where it lets you. build()runs before each signature, so a swap can fetch a new quote and slippage limit before it signs again.- "Landed but failed" (a slippage error, say) is thrown, not retried: the fee is paid, so decide again before sending anything new.
- Three calls every 2 seconds per pending transaction:
getBlockHeight,getSignatureStatusesand the resend. A bot can resend more often. - Pin version 1. npm's
latesttag for@solana/web3.jsnow points at version 3, so install@solana/web3.js@1.
How we tested it: on a local solana-test-validator (Agave 3.1.15) with a throwaway keypair; nothing went to mainnet, devnet or any relay. For the expiry case, a wrapper around the connection dropped every send of the first copy, as if each packet were lost. The script itself ran unchanged. Local slots are slower than mainnet's, so the times below are not mainnet figures.
# A normal transfer
attempt 1: signed 2KiafDEi, valid until block height 407
landed in slot 259 after 1 send(s)
# Every send of the first copy lost
[0.0s] attempt 1: signed 4jfeGmJ6, valid until block height 422
[74.1s] expired: block height 423 > 422, after 37 send(s)
[74.1s] attempt 2: signed 3XLhopKz, valid until block height 573
[76.1s] landed in slot 425 after 1 send(s)
recipient received 1000000 lamports (one transfer = 1000000)
# The first blockhash unknown to the node
[0.0s] attempt 1: signed 22vqHJzK, valid until block height 414
[0.0s] preflight: Blockhash not found; signing again with a fresh blockhash
[0.5s] attempt 2: signed 42PZmJzJ, valid until block height 413
[2.6s] landed in slot 264 after 1 send(s)In the expiry run, the first copy's bytes, delivered after it expired, were refused with Blockhash not found, and sent without preflight they never landed. The recipient was paid once.
skipPreflight: when to turn it off
Preflight does three checks before the node forwards anything: the signatures, a blockhash within the last 150 blocks, and a simulation at preflightCommitment. Agave also refuses when its health check says the node is behind.
- The cost: a simulation on the RPC node before your transaction goes out.
- The benefit: errors come back before you pay a fee, and a
Blockhash not foundtells you nothing was sent. - Without it, the node forwards the transaction without checking its blockhash and returns a signature. In our test, a transaction with a made-up blockhash got a signature back with
skipPreflight: true, and never landed. WithskipPreflight: true, web3.js 1.x and current Agave both useprocessed, whateverpreflightCommitmentyou pass.
Keep preflight on the first send, unless you have just simulated the transaction yourself, and skip it on resends. To test only a transaction's logic, simulateTransaction with replaceRecentBlockhash: true swaps in a valid blockhash; our made-up-blockhash transaction simulated with no error that way.
Signed long before it's sent: durable nonces
If a transaction has to wait for approvals, offline signing or a schedule, a recent blockhash will expire first. A durable nonce replaces the blockhash with a value stored in a nonce account. The first instruction advances the nonce, and the transaction stays valid until that nonce is used. Solana's durable nonce guide covers the setup. The same mechanism lets one trade go out through several senders with at most one copy landing; our sender comparison has that code.
Plan for a change, though. In October 2026 the Solana Foundation announced that durable nonces will be removed about six months after a replacement is ready: an onchain program that verifies an offline signature, with Anza's Programmatic Signer and Blueshift's Vector as candidates. No date is set.
Symptom, cause, fix
| Symptom | Likely cause | Fix |
|---|---|---|
Blockhash not found on the first send | Blockhash from a node ahead of the one that simulated | One node for everything, or resend the same bytes shortly |
Blockhash not found with processed blockhashes | Preflight at finalized (the default), or a dropped block | Fetch at confirmed; set preflightCommitment to match |
Blockhash not found after a wallet prompt or a queue | It expired before the send | Fetch right before signing; durable nonce for long waits |
Blockhash not found on every send | A blockhash from another cluster | Fetch from the cluster you send to |
Node is behind by N slots | The RPC node is lagging | Use a healthy node; nothing was sent |
block height exceeded, signature not found | Dropped, not resent, or outbid | Resend the same bytes until expiry, then sign again |
block height exceeded, but the transfer happened | The WebSocket notification never came | Check getSignatureStatuses before signing again |
This transaction has already been processed | A resend of bytes that already landed | Nothing: it landed once |
| One trade landed twice | A second copy signed before the first expired | Sign again only after lastValidBlockHeight passes |
Sending through AllenHark
- AllenHark Relay lands transactions in the same block, at 0.1 ms over QUIC, so fewer of them sit waiting until their blockhash expires.
- No simulation on the relay.
simulatemust befalse, and the relay rejects simulation requests, so run preflight orsimulateTransactionon your RPC first. - Accepted is not landed. The relay returns the signature once it has sent the transaction on. Track it with
getSignatureStatuses, as the loop above does; resending the same bytes can't land twice or pay the tip twice. Each API key gets 10 requests per second by default, more on request. - Bundles that meet no Jito leader expire with their blockhash. The relay's response carries a warning when no Jito leader is scheduled soon, and the relay docs say to re-sign with a fresh blockhash once a bundle expires.
- AllenHark RPC serves 100 requests per second on the standard plan, and Dedicated RPC has no rate limits. Per the RPC docs,
sendTransactionis forwarded to the node as is. Fetch the blockhash, run preflight and poll statuses on the same endpoint.
FAQ
What does "Blockhash not found" mean on Solana?
The RPC node that simulated your transaction doesn't have its blockhash in the bank it checked, so the transaction wasn't sent. Usually the blockhash came from a node further ahead, was fetched at processed while preflight ran at finalized, or had expired.
How long is a Solana blockhash valid?
About 150 blocks, which is about 30 seconds with slots of about 200 ms. Go by the lastValidBlockHeight that getLatestBlockhash returns, not a timer.
What is lastValidBlockHeight?
The height of the block your blockhash came from, plus 150. Once getBlockHeight at your commitment returns a higher number, no new block will accept the transaction; if getSignatureStatuses doesn't show it by then, it won't land.
Is it safe to resend a Solana transaction?
Resending the same signed bytes is safe: it lands at most once. Signing a new copy is safe only after the first copy's blockhash has expired, or after the node refused the first copy at preflight.
Should I set skipPreflight to true?
Not on the first send, unless you've just simulated the transaction yourself: preflight catches errors before you pay a fee. Skip it on resends of the same bytes.
Related reading
- SWQoS vs Jito vs a relay: which Solana sender to use, including one durable nonce across several senders.
- Why your Solana transactions arrive late even with priority fees.
- The fastest way to land Solana transactions in 2026.
- Alpenglow: what Solana RPC and gRPC users need to change.
- AllenHark Relay docs and RPC docs.
- Method reference: getLatestBlockhash, sendTransaction, getSignatureStatuses, getBlockHeight and simulateTransaction.
Sources
Checked November 2, 2026.
- Solana docs, Transaction confirmation & expiration: the blockhash queue, the 151 recent blockhashes, commitment advice,
preflightCommitment, lagging RPC nodes, dropped forks. - Solana docs, Retrying transactions: RPC pools,
maxRetries, the preflight checks, re-signing only after expiry. - Solana docs, durable nonces and terminology:
AdvanceNonceAccountfirst; block height. - Solana Foundation, Reduced slot times: still 150 blocks, now 30 seconds. Alpenglow:
confirmedandfinalizeddescribe the same state. Durable nonce deprecation: removal about six months after a replacement is ready. - Agave v4.4.0 source: rpc.rs (
getLatestBlockhash, the preflight checks and error text, thefinalizeddefault), bank.rs (lastValidBlockHeight), check_transactions.rs (the 151 blockhashes, already-processed check), status_cache.rs, send_transaction_service.rs (2-second retries to two leaders,maxRetries, the 10,000 queue). Constants from solana-clock (MAX_PROCESSING_AGE150,MAX_RECENT_BLOCKHASHES300). @solana/web3.js1.99.0, connection.ts: the block-height confirmation strategy and howpreflightCommitmentis set.- AllenHark: Relay, Relay docs, RPC, RPC docs.
