The blockhash lifecycle, and why it killed your transaction

Engineering15 min read

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.


the short version
  • 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.

Slots and block height are not the same number, and mixing them up produces off-by-a-lot errors. 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.

StepTypical costRunning total
Fetch blockhash at finalized~13s of window already gone13s
The fetch round trip itself50 to 300ms~13.3s
Build, simulate, sign200ms to 2s if you simulate~15s
Network hop to your RPC20 to 200ms~15.2s
RPC queue and forward to leaderunbounded, 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#

commitment.ts
1import { Connection } from "@solana/web3.js";
2
3const rpc = new Connection(process.env.RPC_URL!, "confirmed");
4
5/**
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. Asking
9 * for one at "finalized" hands you a hash from a block that is already about
10 * 32 slots in the past, so you have spent a fifth of the window before you
11 * 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");
16
17 console.log(commitment, {
18 slotsRemaining: lastValidBlockHeight - height,
19 approxSeconds: ((lastValidBlockHeight - height) * 0.4).toFixed(1),
20 });
21}
22
23// processed ~150 slots ~60s freshest, small risk the block is dropped
24// confirmed ~148 slots ~59s the right default for a sender
25// finalized ~118 slots ~47s safest, and a fifth of the budget gone

The trade-off is real but lopsided for a sender:

  • finalized cannot be rolled back, and costs you about 32 slots of window. Correct for anything you are signing offline or scheduling.
  • confirmed is supermajority-voted and effectively never reverts in practice. Two slots of cost. This is the right default for a live sender.
  • processed is 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.

cache.ts
1import { Connection } from "@solana/web3.js";
2
3/**
4 * Keep a fresh blockhash in memory so building a transaction never waits on
5 * the network.
6 *
7 * The naive pattern fetches a blockhash inside the send path. That puts a
8 * network round trip on your critical path AND spends window while you wait
9 * for it. Refreshing in the background costs one call every couple of seconds
10 * 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;
16
17 constructor(
18 private rpc: Connection,
19 private intervalMs = 2_000,
20 ) {}
21
22 async start() {
23 await this.refresh();
24 this.timer = setInterval(() => void this.refresh(), this.intervalMs);
25 }
26
27 stop() {
28 if (this.timer) clearInterval(this.timer);
29 this.timer = null;
30 }
31
32 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 is
39 // far better than no hash, and RPCs blip. Past that, refuse to serve a
40 // hash we can no longer vouch for rather than sending doomed bytes.
41 if (this.failures > 5) this.current = null;
42 }
43 }
44
45 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.

retry.ts
1import { Connection, VersionedTransaction, Keypair } from "@solana/web3.js";
2import type { BlockhashCache } from "./cache";
3
4/**
5 * Retry by REBUILDING, not by replaying.
6 *
7 * The instinct is to keep resending the same signed bytes until something
8 * sticks. That is precisely wrong: every retry spends the same window, so the
9 * fifth attempt is sending a transaction that is already 20 seconds closer to
10 * 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;
21
22 for (let i = 0; i < attempts; i += 1) {
23 const { blockhash, lastValidBlockHeight } = cache.get();
24 const height = await rpc.getBlockHeight("confirmed");
25
26 // 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 }
31
32 const tx = build(blockhash);
33 tx.sign([payer]);
34
35 const signature = await rpc.sendRawTransaction(tx.serialize(), {
36 skipPreflight: true, // preflight costs a round trip you cannot spare
37 maxRetries: 0, // we are managing retries ourselves
38 });
39
40 const landed = await waitForLanding(rpc, signature, lastValidBlockHeight);
41 if (landed) return { signature, attempt: i + 1 };
42
43 await new Promise((r) => setTimeout(r, gapMs));
44 }
45
46 throw new Error(`did not land after ${attempts} attempts`);
47}
48
49/** 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;
56
57 // Once the chain has passed lastValidBlockHeight the transaction can never
58 // land. Stop waiting; there is nothing left to wait for.
59 if ((await rpc.getBlockHeight("confirmed")) > lastValidBlockHeight) return false;
60
61 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 lastValidBlockHeight the 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.

nonce.ts
1import {
2 Connection, Keypair, SystemProgram, Transaction, NONCE_ACCOUNT_LENGTH,
3 sendAndConfirmTransaction, PublicKey, NonceAccount,
4} from "@solana/web3.js";
5
6/**
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 only
10 * changes when you advance it. The deadline disappears. What you get instead is
11 * a serialization point: one nonce account supports exactly one in-flight
12 * transaction, because advancing it invalidates anything else built on the old
13 * 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);
18
19 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 );
32
33 await sendAndConfirmTransaction(rpc, tx, [payer, nonceAccount]);
34 return nonceAccount.publicKey;
35}
36
37/** 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");
41
42 const state = NonceAccount.fromAccountData(info.data);
43
44 return {
45 recentBlockhash: state.nonce, // the stored nonce goes where the blockhash would
46 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 caseDurable nonce?
Offline or hardware-wallet signingYes, this is what it is for
Multisig collecting signatures over hoursYes
Scheduled or delayed executionYes
A bot sending continuouslyNo, unless you pool nonce accounts
Competing for a specific eventNo. Keep the blockhash fresh instead

Reading the errors#

Three errors point back here, and they mean different things.

ErrorWhat it meansFix
BlockhashNotFoundThe validator has never seen that hashUsually an RPC behind the chain, or a processed hash from a dropped block
Blockhash expiredValid once, now past 150 slotsFresher hash, faster path, rebuild rather than replay
AlreadyProcessedThat exact signature already landedNot 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.

Stop guessing at delivery.

swqos.com forwards your signed transactions to the leader over staked connections held open and kept warm. One prepaid balance, a flat price per send.

Get an API key
keep reading