LND-03
Blockhash and Expiry: The Deadline Every Send Carries
Every signed Solana transaction carries an expiry date written into the message itself. It is measured in blocks rather than seconds, it starts running when the blockhash is fetched rather than when the transaction is sent, and almost every build spends more of it than the author realises.
Fault card
- Symptom
- Sends fail with blockhash not found, or expire with no status
- Mechanism
- The referenced blockhash left the acceptance window before inclusion
- Instrument
- getLatestBlockhash lastValidBlockHeight against getBlockHeight
- Correction
- Move the fetch closer to the signature and measure the gap in blocks
A Solana transaction references a recent blockhash, and validators only accept blockhashes from roughly the last 150 blocks. That reference is a deadline. The transaction is valid until block height passes the last valid height returned with the blockhash, and after that no amount of resending, fee raising or waiting will make it land.
Why a transaction expires at all
The blockhash does two jobs. It makes each signed message unique to a moment in the chain's history, and it puts a bound on how long that message can be replayed. Without the second property a signed transaction would be valid forever, and anyone who ever saw the bytes could resubmit them later against a different account state.
Expiry is therefore a safety feature that the send path has to work around rather than a defect to be engineered away. It is also, from an operator's perspective, the single cleanest failure in the whole system: a transaction that expired did not land, cannot land, and cost nothing. There is no ambiguity to resolve and no fee to account for.
What makes it awkward is that the deadline is invisible in most client code. The blockhash goes into the message and is never looked at again, so the deadline quietly exists without ever being compared to anything. A send path that does not carry the last valid block height alongside the signed bytes has no way to know when to stop trying.
The window is blocks, not seconds
The acceptance window is defined in blocks. Block height increases by one for every block a leader actually produces, and skipped slots do not advance it. Slot numbers, by contrast, advance whether or not a block appears, which is why the two counters drift apart and why using one in place of the other produces subtle bugs.
The practical consequence is that the window has no fixed duration. At the network's target slot time of roughly 400 milliseconds, and with every slot producing a block, 150 blocks is about a minute. During a stretch where a meaningful fraction of scheduled slots produce nothing, the same 150 blocks occupy more wall-clock time. A transaction does not get less window during congestion, it gets more seconds of the same window.
That is worth sitting with, because it contradicts the usual intuition. The reason expiry failures rise when the network is busy is not that the window shrinks. It is that delivery and scheduling get harder within it, so more transactions spend the whole window without ever being packed. Treating the symptom as a timing problem leads to shortening timeouts, which makes the outcome worse rather than better.
Which commitment to fetch at
The blockhash fetch takes a commitment level, and the choice is a genuine trade rather than a default to be ignored. Fetching at a looser level gives you the freshest blockhash and therefore the most remaining window, but that block may not yet be known to every node. Fetching at a stricter level gives you a blockhash every node recognises, at the price of starting several blocks into the window.
| Fetch commitment | Remaining window | Recognised by other nodes | Failure it invites |
|---|---|---|---|
| Processed | Largest | Least reliably | Blockhash not found on a node that is behind |
| Confirmed | Slightly reduced | Broadly | Few, and the usual working choice |
| Finalized | Smallest | Universally | Expiry, because the window is partly spent on arrival |
The rule that removes most confusion is consistency. Fetch the blockhash and run preflight at the same commitment, through the same endpoint, and the phantom class of blockhash-not-found errors caused by two nodes disagreeing about recency simply stops occurring. That is a configuration fix with no runtime cost, which makes it the first one to apply.
Where the window gets spent
Once the blockhash is fetched, everything that happens next comes out of the same budget. Most builds spend more of it than their authors expect, and the spend is usually concentrated in places that feel free.
- Route quoting and instruction assembly, particularly if a quote is fetched after the blockhash rather than before it.
- Simulation, which is one full round trip and is worth its cost, but is a cost.
- Signing, negligible for a single signer and less so when a fleet coordinator has to fetch or unlock a key.
- Queueing inside your own process, which is invisible in logs unless the queue records enqueue and dequeue times.
- Retry sleeps, which are the largest single consumer in most send paths and the easiest to misconfigure.
- Transit and forwarding, which you do not control and which is the smallest of these in practice.
The ordering fix is simple and costs one thing: fetch the blockhash as late as possible, immediately before signing, and pay for that with an extra round trip on every attempt. In a path where quoting and assembly take real time, that trade is usually worth making, because it converts window that was being burned on your own work into window available for delivery.
A worked expiry budget
The arithmetic below is illustrative. The 150-block window is the protocol constant; every other number is chosen to make the ledger legible and is not a measurement of anything.
| Stage | Blocks consumed | Running total | Window left |
|---|---|---|---|
| Blockhash fetched at finalized | 30 | 30 | 120 |
| Route quote and instruction assembly | 15 | 45 | 105 |
| Preflight simulation | 5 | 50 | 100 |
| Queue wait inside the sender | 25 | 75 | 75 |
| First send | 0 | 75 | 75 |
| Four resends at fifteen-block intervals | 60 | 135 | 15 |
| Deadline reached | 15 | 150 | 0 |
Read that table as a diagnosis rather than a recommendation. Half the window was gone before the first send, and only 75 blocks of it were ever available for delivery. Moving the fetch to just before signing recovers the first two rows, roughly 45 blocks, which is more additional delivery time than any retry cadence change can produce.
Two corrections compete here and only one is free. Moving the blockhash fetch later costs one extra round trip per attempt. Extending the retry window costs nothing at all but cannot exceed the deadline. Neither buys anything if the transaction was never competitive at the leader, which is a scheduling problem and lives in the latency section.
Expiry across a batch
A single transaction has one deadline and it is easy to reason about. A batch built from one blockhash has one shared deadline and a staggered set of send times, which produces a failure pattern that looks like a network problem and is not. The last transactions in the batch inherit the least window, so they expire first, in order, for a reason that lives entirely in your own build loop.
The tell is the shape of the failures rather than their count. If expired attempts cluster at the end of each batch and the early ones land cleanly, the batch is outrunning its blockhash. If expired attempts are scattered evenly across the batch, something outside the build is dropping them and the window is not the cause. The same total failure count means two completely different things depending on which pattern it makes.
Two structural corrections exist and they price differently. Refetching the blockhash every few transactions gives each one a nearly full window and costs an extra call per group; the group size is the dial. Splitting the batch into smaller waves with a fresh fetch per wave achieves the same thing while also giving the sender natural pause points, at the cost of a longer total run.
There is a third option that looks attractive and is not: keeping the batch and extending the retry window past the deadline. That does not extend anything. Once the last valid block height passes, further sends of those bytes are requests the network will discard, and the only thing they consume is your endpoint quota. A retry loop that does not read the deadline will happily spend minutes on transactions that died a while ago.
Fleet operation adds one more wrinkle worth naming. When many signers are used, the expensive part of preparing a transaction is often the coordination rather than the cryptography, and that coordination happens before the send. Recording enqueue time, sign time and first-send time per attempt makes the distribution of consumed window visible, and the distribution is what tells you whether a batch size is safe.
Two errors that both say expired
Clients surface expiry in two different shapes and they mean different things. The first is a rejection at submission, where the node evaluating the transaction reports that it does not know the blockhash. The second is a confirmation timeout, where the client stops waiting because the last valid block height has passed with no status.
The first can be an expiry or a view mismatch. If the blockhash was fetched seconds ago and a node claims not to know it, the node is behind, not the transaction old. The second is unambiguous: the deadline was reached and the transaction is permanently dead. Recording them under one label loses that distinction, and the distinction points at completely different corrections.
A confirmation strategy built on block height rather than a timer produces the second error correctly. It waits until the last valid block height has passed, checks the signature one final time, and then closes the attempt. Anything that closes earlier is discarding transactions that were still live, and anything that waits longer is burning time on a transaction that cannot land.
Durable nonces and what they cost
A durable nonce replaces the recent blockhash with a value stored in a nonce account. The transaction stays valid until that stored value is advanced, which removes expiry as a constraint entirely. The mechanism is documented as part of Solana's transaction model, and it exists for flows where signing and sending are separated by more time than a blockhash allows.
It is not free. The nonce account must be created and kept rent-exempt, which locks SOL. The advance-nonce instruction has to be the first instruction in the transaction, which consumes one of your limited instruction slots and a little compute. Most importantly, a nonce account can only support one in-flight transaction at a time, because advancing the nonce is what invalidates the previous one. Concurrency therefore requires a pool of nonce accounts, and each one carries its own locked balance.
| Property | Recent blockhash | Durable nonce |
|---|---|---|
| Lifetime | About 150 blocks | Until the nonce is advanced |
| Setup cost | One RPC call | Account creation plus rent-exempt balance |
| Concurrency | Unlimited from one fetch | One in-flight transaction per nonce account |
| Extra instruction | None | Advance nonce, and it must be first |
| Natural fit | Live trading and automated sends | Offline signing, scheduled or approved transfers |
For a live send path the honest answer is usually that a nonce is the wrong tool. The problem it solves is time between signing and sending, and a trading engine's problem is delivery and scheduling within a window that is already long enough. Reaching for a durable nonce to fix expiry under load is treating a symptom that a later blockhash fetch would have fixed for one round trip.
Practice rules for the send path
Six rules cover almost every expiry problem, and each one states its price so the trade can be refused.
- Carry the deadline with the bytes. Store the last valid block height next to the signed transaction. Costs one field in your record and removes all guessing about when to stop.
- Fetch the blockhash immediately before signing. Costs one round trip per attempt and returns the largest single block of usable window.
- Fetch and simulate at the same commitment, through the same endpoint. Costs nothing and eliminates the view-mismatch class of blockhash errors.
- Close attempts on block height, never on a timer. Costs a periodic height poll and makes the close decision correct during irregular block production.
- Do not reuse one blockhash across a long batch. Costs more fetches; the alternative is a tail of transactions that expire purely because they were built last.
- Count expired attempts in their own bucket. Costs a counter and prevents an expiry problem from being read as a fee problem, which is the most expensive misdiagnosis available.
These rules are the same whether the sender is fifty lines of your own code or a hosted service. If you are evaluating a volume bot for Solana rather than writing one, they convert into questions: when is the blockhash fetched, what closes an attempt, and does the report separate expired from failed. A tool that cannot answer those three has not thought about the deadline any harder than a first draft would.
Expiry is the cheapest problem in this log to fix and the one most often left in place, because it hides behind other symptoms. A run with a poor landing rate and a badly ordered build will respond to a fee increase, which makes the fee look like the answer. It is not; the window was simply too short, and it was too short for reasons that cost nothing to correct.
Questions this entry keeps getting
How long is a Solana blockhash valid?
A blockhash is accepted for roughly 150 blocks after the block it came from. Because block height advances only when a leader actually produces a block, that window has no fixed length in seconds. At the target slot time it is close to a minute, but a stretch with skipped slots makes the same 150 blocks take longer in wall-clock terms.
Does the expiry clock start when I send the transaction?
No, it starts when the blockhash was produced. Everything between fetching it and a leader packing your transaction is spent from the same budget: signing, queueing, retry sleeps and network transit. A build that takes ten seconds before its first send has already used part of a window it never sees.
Should I track expiry using a timer?
A timer is a fallback, not a measurement. The correct deadline is the last valid block height returned alongside the blockhash, compared against the current block height. A timer will be wrong in both directions during irregular block production, and being wrong in the optimistic direction means abandoning transactions that could still land.
What is a durable nonce used for?
It replaces the recent blockhash with a value stored in an account you control, so the transaction does not expire until that nonce is advanced. It suits offline signing, multi-party approval and any flow where signing and sending are separated in time. It is not a general fix for congestion, and it changes the duplicate-safety properties of a retry.
Can I reuse one blockhash for many transactions?
Yes, and it is normal to do so within a short burst, but every transaction built on it inherits the same deadline. Reusing a blockhash across a long run means later transactions start with less remaining window than earlier ones, and the tail of the batch will expire first for reasons unrelated to the network.
Is blockhash not found always an expiry problem?
No. It also appears when the node evaluating the transaction has not yet seen the block your blockhash came from, which is a view mismatch rather than an expiry. Fetching and sending through the same endpoint, at the same commitment, removes that second cause and leaves only genuine expiry behind.
Filed under Landing. Arithmetic on this page is either a documented protocol constant or an illustrative example built from numbers you supply. If a figure here is wrong or has moved, send the desk a correction and the entry gets amended rather than quietly rewritten.