Your code waited thirty seconds, gave up, and logged a failure. The transaction landed at second thirty-one.
★A timeout describes your patience, not the chain's state.★ Recording it as a failure is how accounting drifts away from reality.
The Error That Means Nothing
// ★This throws on a timer, not on an outcome.★
await connection.confirmTransaction(sig, "confirmed");
// TransactionExpiredTimeoutError: Transaction was not confirmed in 30.00 seconds
The message is honest and widely misread. It says the client stopped waiting. It does not say the transaction failed, was rejected, or will not land.
★Three things are still possible when this fires:★
- It already landed and the confirmation was slow to propagate
- It is still pending inside a valid blockhash window
- It expired and genuinely never ran
Only the third is a failure, and this error cannot tell you which one you have.
Block Height Is the Authority
// ★Wrong instrument.★
setTimeout(() => markFailed(sig), 30_000);
// ★Right one.★
const height = await connection.getBlockHeight();
if (height > lastValidBlockHeight) {
// Permanently invalid. Now it is a real outcome.
}
★Slot production varies, so a fixed number of seconds maps to a variable number of slots.★ Thirty seconds might be sixty slots when the network is healthy and far fewer when it is not — and congestion is exactly when your timeout fires.
A wall-clock timeout is guaranteed to be wrong in one direction or the other. Block height is the only measure that matches how validity actually works.
Where Wall-Clock Timeouts Do Belong
They are not useless — they belong on the transport, not on the outcome:
// ★Timeout the HTTP call.★
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5_000);
try {
const sig = await sendWithSignal(raw, controller.signal);
} finally {
clearTimeout(t);
}
★A network call that hangs should be abandoned; a transaction that is in flight should not.★ Timeout the request, then continue resolving the signature separately — those are different clocks measuring different things.
If transactions are genuinely expiring rather than timing out on your side, a free BoltTx key is one line to test the submission path.
The Send Timeout Trap
An aborted send is the most dangerous timeout, because the transaction may have arrived anyway:
try {
sig = await connection.sendRawTransaction(raw, { skipPreflight: true });
} catch (e) {
// ★Do NOT assume it was not submitted.★
// The bytes may have reached the node before the response was lost.
}
★The signature is deterministic from the signed bytes, so you can compute it without the response.★
import bs58 from "bs58";
// tx is a VersionedTransaction — signatures[0] is raw bytes.
const sig = bs58.encode(tx.signatures[0]); // ★known before sending★
Compute it up front, and a lost response stops being ambiguous — you already have the signature to check. Rebuilding after a failed send, without checking, is one of the two classic paths to a double execution.
Resolve to a State, Not to a Timer
async function resolve(connection, sig, lastValidBlockHeight) {
while (true) {
const { value } = await connection.getSignatureStatuses([sig]);
const st = value[0];
if (st?.confirmationStatus === "confirmed" ||
st?.confirmationStatus === "finalized") {
return st.err ? { status: "reverted", err: st.err } : { status: "success" };
}
if (await connection.getBlockHeight() > lastValidBlockHeight) {
const final = await connection.getSignatureStatuses([sig]);
if (final.value[0]) { await sleep(400); continue; } // ★landed at the last moment★
return { status: "expired" };
}
await sleep(400);
}
}
★This loop has no timeout because it does not need one.★ The blockhash window bounds it — every path terminates in success, reverted, or expired, and none of them is "we stopped waiting."
What To Do When a Timeout Fires Anyway
In a long-running service you will still hit a timeout at the process level — a deploy, a restart, a supervisor limit. The correct handling is to hand the signature off rather than to decide:
if (elapsed > softLimit) {
await queue.push({ sig, lastValidBlockHeight, intent });
return { status: "handed_off" }; // ★not a failure★
}
★"Handed off" is a legitimate state and "failed" is not.★ A background worker resolves it properly, and your metrics stay honest because you never recorded an outcome you did not observe.
For a user-facing bot, say the true thing: "still confirming" rather than "failed." A user told their trade failed, who then sees it succeeded on chain, trusts the next message less.
What This Does to Your Metrics
★A timeout logged as a failure poisons every rate you compute from it.★
| Recorded | Reality | Consequence |
|---|---|---|
| Timeout as failure | Landed late | ★Success rate understated★ |
| Timeout as failure | Still pending | Duplicate on retry |
| Timeout as success | Never landed | ★Phantom trades in P&L★ |
The fix is to make the unresolved case explicit:
metrics.record({ status: "success" | "reverted" | "expired" | "unresolved" });
★A rising unresolved count is itself a useful signal★ — it means your resolution path is being cut short, which is a different problem from transactions not landing.
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
★Against that number, a thirty-second timeout is enormously longer than a normal landing.★ When a timeout fires routinely, the problem is usually the submission path rather than the timeout value.
Where BoltTx Fits
We handle submission. Timeout and confirmation logic stays in your code.
Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so a transaction is not observable in transit before it lands.
You sign locally. We never hold funds, never sign, and never modify transaction contents — so a signature you computed before sending is the signature you can check afterwards. The tip travels inside the transaction, paid on chain from your own wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. You pay only on transactions that reach the chain.
Get a free API key. No monthly fee:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
FAQ
What does TransactionExpiredTimeoutError mean? That your client stopped waiting, not that the transaction failed. It may have already landed, may still be pending, or may have expired — the error cannot distinguish them.
Should I use a timeout when confirming Solana transactions?
Not for deciding the outcome. Bound the loop by comparing block height against lastValidBlockHeight, which is the same measure the network uses for validity.
Why is a wall-clock timeout unreliable? Slot production varies, so a fixed number of seconds covers a variable number of slots. It will be too short or too long depending on network conditions, and congestion is when it fires.
Where should timeouts be used then? On the transport. Abort an HTTP request that hangs, but continue resolving the signature separately, since the request and the transaction are different things on different clocks.
What if my send call times out? Do not assume it was not submitted. Compute the signature from the signed bytes before sending, then check its status — the bytes may have arrived before the response was lost.
How do I compute a signature before sending? Base58-encode the first signature in the signed transaction. It is deterministic from the bytes, so a lost response never leaves you without something to check.
What should I record when a timeout fires? An explicit unresolved state, not a failure. Then hand the signature to a background worker that resolves it properly against the block height limit.
Why does recording timeouts as failures matter? It understates your success rate and can trigger a retry that double-executes. Metrics built on outcomes you never observed will not match the chain.
What should I tell a user when confirmation is slow? That it is still confirming. Telling someone a trade failed when it later succeeds on chain damages trust more than the delay itself does.
How long should I wait before handing off? Long enough that most transactions resolve inline, short enough that a request does not block. The handoff exists so the process limit never forces you to guess an outcome.
Is confirmTransaction safe to use?
It is convenient and it makes the timeout decision for you, which is the problem. Polling getSignatureStatuses with a block-height bound gives you the control you need.
Can a transaction land after my timeout fires? Yes, routinely. The blockhash stays valid for roughly 150 blocks regardless of what your client decided, so late landings are expected rather than anomalous.
How do I avoid double-executing after a timeout? Resolve the original signature before rebuilding anything. Resending identical bytes is always safe, while rebuilding after an unobserved landing is what causes duplicates.
Should the retry loop have a timeout? No. The blockhash window already bounds it, and every path terminates in success, reverted, or expired. Adding a timer introduces an outcome the chain does not have.
What does a rising unresolved rate mean? Your resolution path is being cut short by restarts, deploys, or process limits, rather than transactions failing to land. Those are different problems with different fixes.
Does a timeout cost me anything? Not by itself. The cost comes from what you do next — a rebuild after an unobserved landing costs a second execution, and a mis-recorded failure costs accuracy.