The blockhash lifecycle, and why it killed your transaction
A Solana blockhash is valid for 150 slots, roughly 60 to 90 seconds, and a transaction referencing an older one is rejected outright. This explains the full lifecycle, why the window is shorter in practice than the arithmetic suggests, and how to structure a sender so it never fights the clock.
- A blockhash is valid for 150 slots, roughly 60 seconds, from the block that produced it.
- Fetching at finalized commitment spends about a fifth of that window before you have built anything.
- Retrying the same signed bytes spends the same window. Rebuild with a fresh blockhash instead.
- A durable nonce removes the deadline entirely, at the cost of serialising one transaction at a time per nonce account.
Every Solana transaction carries a recent blockhash, and that value is doing more work than most people realise. It is a nonce, a deadline, and a replay guard all at once, and misunderstanding any of those three roles produces a specific and avoidable class of failure.
Understanding the lifecycle properly is one of the cheapest improvements available to a sender, because unlike fees or delivery infrastructure it costs nothing to fix.
Why a blockhash exists at all#
Solana has no account nonce in the Ethereum sense. Without something else, a signed transaction would be replayable forever: anyone could rebroadcast your transfer next year and it would execute again.
The recent blockhash solves this with a bounded window. A validator accepts a transaction only if:
- the referenced blockhash appeared in the last 150 blocks, and
- that exact signature has not already been processed within the window.
So replay protection comes from a rolling window of recently seen signatures, and the blockhash is what keeps that window bounded. A validator only has to remember 150 blocks of signatures rather than all of history.
The consequence is that the blockhash is a deadline. Not an approximation of one, a hard one: once the chain passes lastValidBlockHeight, that transaction can never land, no matter what.
The 150 slot window#
150 slots at a target of 400ms per slot is 60 seconds. In practice it is usually a little longer in wall-clock terms, because slots are sometimes skipped and the network does not always hit its target, but you should never design as though you have more than 60 seconds.
lastValidBlockHeight is a block height. Skipped slots advance the slot number without advancing block height, so compare it against getBlockHeight, never against getSlot.Where the window actually goes#
Sixty seconds sounds generous. Here is where a naive sender spends it.
| Step | Typical cost | Running total |
|---|---|---|
Fetch blockhash at finalized | ~13s of window already gone | 13s |
| The fetch round trip itself | 50 to 300ms | ~13.3s |
| Build, simulate, sign | 200ms to 2s if you simulate | ~15s |
| Network hop to your RPC | 20 to 200ms | ~15.2s |
| RPC queue and forward to leader | unbounded, and invisible | ? |
A quarter of the budget is gone before the transaction leaves your process, and the largest line item is the commitment level you chose without thinking about it.
Commitment changes everything#
1import { Connection } from "@solana/web3.js";23const rpc = new Connection(process.env.RPC_URL!, "confirmed");45/**6 * The same call at three commitments, and what each costs you up front.7 *8 * A blockhash is valid for 150 slots from the block that produced it. Asking9 * for one at "finalized" hands you a hash from a block that is already about10 * 32 slots in the past, so you have spent a fifth of the window before you11 * have built anything.12 */13for (const commitment of ["processed", "confirmed", "finalized"] as const) {14 const { blockhash, lastValidBlockHeight } = await rpc.getLatestBlockhash(commitment);15 const height = await rpc.getBlockHeight("confirmed");1617 console.log(commitment, {18 slotsRemaining: lastValidBlockHeight - height,19 approxSeconds: ((lastValidBlockHeight - height) * 0.4).toFixed(1),20 });21}2223// processed ~150 slots ~60s freshest, small risk the block is dropped24// confirmed ~148 slots ~59s the right default for a sender25// finalized ~118 slots ~47s safest, and a fifth of the budget gone
The trade-off is real but lopsided for a sender:
finalizedcannot be rolled back, and costs you about 32 slots of window. Correct for anything you are signing offline or scheduling.confirmedis supermajority-voted and effectively never reverts in practice. Two slots of cost. This is the right default for a live sender.processedis the freshest and can reference a block that gets dropped, in which case your transaction is invalid. Rarely worth the extra two slots.
Switching from finalized to confirmed is usually a one-word change that returns a quarter of your window. It is the highest-value line of code in this entire post.
A background refresher#
The second improvement is to stop fetching blockhashes on the critical path at all. Keep a fresh one in memory and hand it to the builder.
1import { Connection } from "@solana/web3.js";23/**4 * Keep a fresh blockhash in memory so building a transaction never waits on5 * the network.6 *7 * The naive pattern fetches a blockhash inside the send path. That puts a8 * network round trip on your critical path AND spends window while you wait9 * for it. Refreshing in the background costs one call every couple of seconds10 * and removes both problems.11 */12export class BlockhashCache {13 private current: { blockhash: string; lastValidBlockHeight: number } | null = null;14 private timer: ReturnType<typeof setInterval> | null = null;15 private failures = 0;1617 constructor(18 private rpc: Connection,19 private intervalMs = 2_000,20 ) {}2122 async start() {23 await this.refresh();24 this.timer = setInterval(() => void this.refresh(), this.intervalMs);25 }2627 stop() {28 if (this.timer) clearInterval(this.timer);29 this.timer = null;30 }3132 private async refresh() {33 try {34 this.current = await this.rpc.getLatestBlockhash("confirmed");35 this.failures = 0;36 } catch {37 this.failures += 1;38 // Keep serving the old hash for a few failures: a slightly stale hash is39 // far better than no hash, and RPCs blip. Past that, refuse to serve a40 // hash we can no longer vouch for rather than sending doomed bytes.41 if (this.failures > 5) this.current = null;42 }43 }4445 get(): { blockhash: string; lastValidBlockHeight: number } {46 if (!this.current) throw new Error("no fresh blockhash available");47 return this.current;48 }49}
Note the failure handling. Serving a slightly stale hash through a brief RPC blip is much better than failing to send, but there is a limit past which you are shipping bytes you know are doomed. Five failures is a reasonable place to draw that line; the important thing is that the line exists.
Retrying without burning the window#
This is the mistake that quietly wrecks otherwise well-built senders. The natural retry loop resends the same signed bytes. That feels safe, because the signature is stable and duplicates are rejected harmlessly.
But every retry is spending the same window. Attempt five is not a fresh chance, it is a transaction twenty seconds nearer to expiry than attempt one, and the later attempts have a worse chance than the earlier ones. You are retrying into a shrinking probability.
1import { Connection, VersionedTransaction, Keypair } from "@solana/web3.js";2import type { BlockhashCache } from "./cache";34/**5 * Retry by REBUILDING, not by replaying.6 *7 * The instinct is to keep resending the same signed bytes until something8 * sticks. That is precisely wrong: every retry spends the same window, so the9 * fifth attempt is sending a transaction that is already 20 seconds closer to10 * death than the first. Sign a fresh one instead.11 */12export async function sendWithRetries(13 rpc: Connection,14 cache: BlockhashCache,15 build: (blockhash: string) => VersionedTransaction,16 payer: Keypair,17 opts: { attempts?: number; gapMs?: number } = {},18) {19 const attempts = opts.attempts ?? 3;20 const gapMs = opts.gapMs ?? 2_000;2122 for (let i = 0; i < attempts; i += 1) {23 const { blockhash, lastValidBlockHeight } = cache.get();24 const height = await rpc.getBlockHeight("confirmed");2526 // Do not bother sending into a window that cannot survive the round trip.27 if (lastValidBlockHeight - height < 30) {28 await new Promise((r) => setTimeout(r, 400));29 continue;30 }3132 const tx = build(blockhash);33 tx.sign([payer]);3435 const signature = await rpc.sendRawTransaction(tx.serialize(), {36 skipPreflight: true, // preflight costs a round trip you cannot spare37 maxRetries: 0, // we are managing retries ourselves38 });3940 const landed = await waitForLanding(rpc, signature, lastValidBlockHeight);41 if (landed) return { signature, attempt: i + 1 };4243 await new Promise((r) => setTimeout(r, gapMs));44 }4546 throw new Error(`did not land after ${attempts} attempts`);47}4849/** Poll until the transaction lands or its blockhash provably expired. */50async function waitForLanding(rpc: Connection, signature: string, lastValidBlockHeight: number) {51 while (true) {52 const { value } = await rpc.getSignatureStatuses([signature], {53 searchTransactionHistory: true,54 });55 if (value[0]?.confirmationStatus) return true;5657 // Once the chain has passed lastValidBlockHeight the transaction can never58 // land. Stop waiting; there is nothing left to wait for.59 if ((await rpc.getBlockHeight("confirmed")) > lastValidBlockHeight) return false;6061 await new Promise((r) => setTimeout(r, 500));62 }63}
Three things in there are load bearing:
- Rebuild each attempt with the current freshest blockhash, so every attempt gets a full window.
- Refuse to send into a dying window. Under 30 slots there is no realistic chance of the round trip completing, so sending is pure waste.
- Stop waiting once the height passes. Past
lastValidBlockHeightthe transaction is provably dead. Polling further is burning quota on a certainty.
Durable nonces#
When a deadline genuinely does not suit the shape of what you are doing, a durable nonce removes it.
1import {2 Connection, Keypair, SystemProgram, Transaction, NONCE_ACCOUNT_LENGTH,3 sendAndConfirmTransaction, PublicKey, NonceAccount,4} from "@solana/web3.js";56/**7 * Create a durable nonce account once, then reuse it forever.8 *9 * A durable nonce replaces the recent blockhash with a stored value that only10 * changes when you advance it. The deadline disappears. What you get instead is11 * a serialization point: one nonce account supports exactly one in-flight12 * transaction, because advancing it invalidates anything else built on the old13 * value.14 */15export async function createNonceAccount(rpc: Connection, payer: Keypair, authority: PublicKey) {16 const nonceAccount = Keypair.generate();17 const rent = await rpc.getMinimumBalanceForRentExemption(NONCE_ACCOUNT_LENGTH);1819 const tx = new Transaction().add(20 SystemProgram.createAccount({21 fromPubkey: payer.publicKey,22 newAccountPubkey: nonceAccount.publicKey,23 lamports: rent,24 space: NONCE_ACCOUNT_LENGTH,25 programId: SystemProgram.programId,26 }),27 SystemProgram.nonceInitialize({28 noncePubkey: nonceAccount.publicKey,29 authorizedPubkey: authority,30 }),31 );3233 await sendAndConfirmTransaction(rpc, tx, [payer, nonceAccount]);34 return nonceAccount.publicKey;35}3637/** Build against the stored nonce. The advance instruction MUST come first. */38export async function buildDurable(rpc: Connection, noncePubkey: PublicKey, authority: PublicKey) {39 const info = await rpc.getAccountInfo(noncePubkey);40 if (!info) throw new Error("nonce account missing");4142 const state = NonceAccount.fromAccountData(info.data);4344 return {45 recentBlockhash: state.nonce, // the stored nonce goes where the blockhash would46 firstInstruction: SystemProgram.nonceAdvance({47 noncePubkey,48 authorizedPubkey: authority,49 }),50 };51}
The trade is explicit and worth stating plainly. You have swapped a time limit for a concurrency limit: one nonce account supports one in-flight transaction, because advancing the nonce invalidates anything else built on the previous value.
| Use case | Durable nonce? |
|---|---|
| Offline or hardware-wallet signing | Yes, this is what it is for |
| Multisig collecting signatures over hours | Yes |
| Scheduled or delayed execution | Yes |
| A bot sending continuously | No, unless you pool nonce accounts |
| Competing for a specific event | No. Keep the blockhash fresh instead |
Reading the errors#
Three errors point back here, and they mean different things.
| Error | What it means | Fix |
|---|---|---|
BlockhashNotFound | The validator has never seen that hash | Usually an RPC behind the chain, or a processed hash from a dropped block |
Blockhash expired | Valid once, now past 150 slots | Fresher hash, faster path, rebuild rather than replay |
AlreadyProcessed | That exact signature already landed | Not a failure. Your first attempt worked. Check before retrying |
AlreadyProcessed gets misread as a failure constantly, and the consequence is expensive: a retry loop that treats it as a failure will keep trying to re-execute a trade that already happened. Treat it as success and reconcile against the chain.If you have tightened all of this and transactions still vanish without a blockhash error, the window was never your problem. Work through the full diagnostic, because something further down the path is dropping them.