If you've debugged enough Solana transaction failures, you've eventually arrived at "the blockhash expired." It sounds boring; it's actually one of the most common silent failure modes for production Solana code. Most bot operators we've talked to have lost meaningful trades to blockhash expiry without realising it for weeks.
This piece covers what blockhashes are, why they expire, what to do about it, and the specific patterns that distinguish robust production code from the brittle stuff that fails at the worst possible moment.
What a Blockhash Is
A Solana blockhash is a recent block's hash, used as part of a transaction's signing context. The transaction is signed against this specific hash, which means:
- The transaction can only be processed when this blockhash is still recent (within ~150 blocks, roughly 60-90 seconds)
- After that window, the transaction is no longer accepted
- This is a feature, not a bug — it prevents replay attacks
When you call getLatestBlockhash, you get a recent blockhash to sign against. When you submit the transaction, validators check that the blockhash is still recent enough.
Why It Expires
The 150-block validity window has two purposes:
- Replay prevention. A transaction signed last week can't be resubmitted now.
- Fee market sanity. Old transactions don't pile up as the network's perception of priority fees evolves.
The window is short on Solana — much shorter than analogous mechanisms on Ethereum (where transactions can sit in the mempool indefinitely). This is a consequence of Solana's fast block times: 60-90 seconds is many slots.
The practical consequence: your transactions need to be submitted with fresh blockhashes, and your retry logic needs to refresh blockhashes between attempts.
How Blockhash Expiry Causes Silent Failures
The pattern that bites most people:
// Don't do this in production.
const tx = new Transaction().add(instruction);
const { blockhash } = await connection.getLatestBlockhash();
tx.recentBlockhash = blockhash;
tx.sign(payer);
for (let i = 0; i < 5; i++) {
try {
const sig = await connection.sendTransaction(tx, [payer]);
return sig;
} catch (e) {
await sleep(2000); // 2 seconds between retries
}
}
After 5 retries with 2-second delays, you've spent 10 seconds. That's well within the blockhash validity window — usually. But if the RPC was slow on each attempt, or you got rate-limited and waited longer, you can easily exceed 60 seconds. Your last few retries are being submitted against an expired blockhash. The RPC may surface this as an error, may silently drop, may return success but the transaction never lands.
The result: your bot thinks it submitted; nothing landed; you don't know why.
The Pattern That Works
Refresh blockhashes between retry attempts:
async function sendWithRetry(buildTx, connection, signers, maxAttempts = 2) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Fresh blockhash for each attempt
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("confirmed");
const tx = buildTx(blockhash);
tx.sign(...signers);
try {
const sig = await connection.sendTransaction(tx, signers, {
skipPreflight: true,
maxRetries: 0,
});
// Wait for confirmation, with the lastValidBlockHeight as the deadline
const confirmed = await waitWithDeadline(
connection, sig, lastValidBlockHeight, 30_000
);
if (confirmed) return sig;
} catch (e) {
// Log, decide whether to continue
}
}
throw new Error("Transaction failed after retries");
}
Key points:
- Each attempt gets a fresh blockhash
maxRetries: 0on the RPC side; we manage retries ourselves- We use
lastValidBlockHeightas the confirmation deadline - We don't retry past 2 attempts; if it didn't land in two tries, the opportunity is probably gone
Choosing the Right Commitment for Blockhashes
getLatestBlockhash accepts a commitment parameter:
"processed"— fastest, but the blockhash can be invalidated by short forks"confirmed"— slightly older, but stable"finalized"— fully final, oldest
For most production code, "confirmed" is right. You want a blockhash that's stable enough not to be reorganised but still recent enough that you have plenty of validity window left.
"processed" blockhashes are tempting for latency-sensitive code (you get the freshest possible), but the rare cases where they get invalidated cost you whole transactions. Not worth the optimisation for most workloads.
Common Blockhash Anti-Patterns
Things we've seen in production code that cause problems:
Pre-fetching blockhashes. Code that calls getLatestBlockhash once at startup and reuses it. Works for the first few transactions; breaks after a minute.
Caching blockhashes. Some code caches blockhashes for "5 seconds to reduce RPC calls." Often the cached blockhash is older than that by the time it's used.
Building transactions long before signing. If you build a transaction (with blockhash) and queue it to be signed and submitted later, the blockhash is already old by the time you submit.
Not handling "blockhash not found" errors. The RPC tells you the blockhash isn't recognised; many bot codebases just retry with the same hash instead of refreshing.
Excessive simulation before submission. Simulating a transaction takes time; if you simulate too many times before submitting, the blockhash you simulated against may no longer be valid.
Long retry chains. "I retry up to 10 times with 1 second between attempts." That's 10+ seconds of latency, well into blockhash expiry territory.
Special Cases: Durable Nonces
For long-running transactions where blockhash expiry is structurally a problem, Solana has durable nonces. These are dedicated nonce accounts that don't expire on the blockhash window — useful for offline signing, multi-sig flows, or any case where signing happens significantly before submission.
Brief overview:
// Create a nonce account
const noneAccount = await createNonceAccount(connection, payer);
// Use the nonce as the recent blockhash
const tx = new Transaction({ feePayer: payer.publicKey, recentBlockhash: nonceValue });
tx.add(SystemProgram.nonceAdvance({ noncePubkey: noneAccount, authorizedPubkey: payer.publicKey }));
tx.add(yourActualInstruction);
For 99% of trading bot use cases you don't need durable nonces — you should just use fresh recent blockhashes. Durable nonces are for cases where the gap between signing and submission is measured in minutes or hours.
For the dedicated treatment, see our durable nonce documentation.
What to Do This Week
If you've been seeing transactions silently fail or land inconsistently:
- Audit your retry logic. Are you reusing blockhashes across retries? If yes, that's likely the cause.
- Set
maxRetries: 0on your sendTransaction calls. Manage retries yourself with fresh blockhashes. - Use
"confirmed"commitment forgetLatestBlockhashunless you have a specific reason for"processed". - Stop retrying past 2 attempts. If it didn't land in two tries, it's not landing.
- Add per-signature telemetry. When a transaction fails, log the blockhash, attempt count, and the lastValidBlockHeight at submission. Lets you see which failures are blockhash-related.
- For long-signing flows, consider durable nonces. Otherwise, refresh blockhashes aggressively.
What BoltTx Provides
BoltTx handles transaction submission with the right defaults:
- Sub-second confirmation so transactions land well within blockhash validity
- Per-signature delivery telemetry — for any transaction, see whether it was rejected for blockhash expiry vs other reasons
- Native Anti-MEV routing so transactions aren't held up by sandwich front-running
- SWQoS-aware delivery for inclusion under congestion (when blockhash issues spike)
import { Connection } from "@solana/web3.js";
const connection = new Connection(
"https://bolttx.io/?api-key=YOUR_API_KEY",
"processed"
);
Free tier signup. Run real workload for a week and compare your blockhash-related failure rate.
FAQ
How long does a blockhash stay valid? About 150 blocks, roughly 60-90 seconds depending on network speed.
What's the difference between blockhash and lastValidBlockHeight? The blockhash is the value you sign against; the lastValidBlockHeight is the block number after which this blockhash is no longer accepted. Use both for proper retry logic.
Should I always use "confirmed" commitment for getLatestBlockhash? Yes for production. "processed" is faster but can be invalidated by reorgs. The rare invalidation cost outweighs the latency saving.
Does setting maxRetries higher help? No — it usually causes silent expiry of blockhashes during the retry chain. Set it to 0 and manage retries yourself.
Are blockhash issues more common during congestion? Yes. Congestion delays inclusion, which means blockhashes are more likely to expire before the transaction lands.