"Add retries" is standard advice for Solana, and it is right. But retries are not one thing — the pattern that works for submitting a transaction is the wrong pattern for handling a rate limit, and using one for the other makes both worse.
Three patterns, three purposes.
First: What You Are Retrying
The pattern depends entirely on what failed, and there are three categories that look similar in logs but behave completely differently.
| Failure | Retry? | Pattern |
|---|---|---|
| Transaction not yet included | ★yes★ | ★one attempt per slot★ |
429 rate limited |
yes | ★exponential backoff★ |
Network timeout, 5xx |
yes | exponential backoff |
| ★Blockhash expired★ | ★no — re-sign★ | rebuild |
| ★Landed with an error★ | ★no★ | fix the instruction |
| Insufficient funds | no | fix the balance |
★The last three are not retryable, and retrying them is how bots burn fees for hours producing nothing.★
Distinguishing them takes one call:
const { value } = await connection.getSignatureStatuses([sig], {
searchTransactionHistory: true,
});
if (!value[0]) { /* not included yet — retry */ }
else if (value[0].err) { /* ★landed and failed — do not retry★ */ }
else { /* landed and succeeded */ }
If your retries are already correct and transactions still need many attempts, a free BoltTx key is one line to test the routing side against.
Pattern 1: One Attempt Per Slot
For getting a transaction included. This is the pattern most people mean when they say "retry."
async function submitUntilLanded(
connection: Connection,
raw: Buffer,
lastValidBlockHeight: number,
): Promise<string | null> {
const sig = await connection.sendRawTransaction(raw, {
skipPreflight: true,
maxRetries: 0, // ★we drive this ourselves★
});
while (true) {
const height = await connection.getBlockHeight("confirmed");
if (height > lastValidBlockHeight) return null; // ★expired: rebuild★
const { value } = await connection.getSignatureStatuses([sig]);
if (value[0]) return sig; // landed; check .err
// Same bytes, same signature — the network includes it at most once.
await connection.sendRawTransaction(raw, {
skipPreflight: true,
maxRetries: 0,
});
await new Promise((r) => setTimeout(r, 400)); // roughly one slot
}
}
★No backoff here.★ Backing off means fewer attempts in a window that is already short, and each attempt is a fresh chance at a new block producer. The correct interval is roughly one slot — faster wastes bandwidth on the same producer, slower skips opportunities.
Why maxRetries: 0. The RPC's own retry runs on a schedule you cannot observe. With two components resending on different clocks, you cannot reason about behaviour or reproduce a problem. Only your loop knows the expiry.
Pattern 2: Exponential Backoff With Jitter
For rate limits and transport errors. Here backoff is exactly right, because the problem is that you are asking too often.
async function withBackoff<T>(fn: () => Promise<T>, max = 5): Promise<T> {
for (let i = 0; i < max; i++) {
try {
return await fn();
} catch (e) {
if (i === max - 1) throw e;
const base = 200 * 2 ** i;
// ★Jitter: without it every worker retries on the same tick
// and recreates the burst that caused the 429.★
await new Promise((r) => setTimeout(r, base + Math.random() * base));
}
}
throw new Error("unreachable");
}
★Retrying a 429 without backoff multiplies your request rate at the exact moment you are over the limit.★ It turns a brief overage into a sustained one.
The jitter matters more than people expect. Ten workers that all got a 429 and all wait exactly the same interval produce the identical spike again.
Pattern 3: Fixed Interval
For polling something that changes on its own schedule — a price feed, a position's health, an account you cannot subscribe to.
setInterval(async () => {
const state = await connection.getMultipleAccountsInfo(watched);
evaluate(state);
}, 1_000);
★This is not really error handling, and it should never be your submission pattern.★ A fixed 2-second interval during a submission window wastes most of the window and gives you three attempts where you had time for a dozen.
The Mistake That Costs Most
// Looks reasonable. Silently useless.
for (let i = 0; i < 5; i++) {
await connection.sendRawTransaction(raw);
await sleep(2000);
}
Two problems compound here.
★It keeps sending after the blockhash expired.★ Attempts three through five are transmitting bytes that are already permanently invalid. Nothing reports this — they just do nothing.
★And 2-second gaps waste the window.★ Roughly a minute of validity, used for five attempts, four of which were probably dead.
The fix is not "retry more." It is checking getBlockHeight against lastValidBlockHeight and stopping when the window closes.
Expiry Means Rebuild, Not Resend
The distinction people miss most often.
if (height > lastValidBlockHeight) {
// ★Wrong: these bytes will never be valid again.★
// await connection.sendRawTransaction(raw);
// Right: fresh blockhash, re-sign, new signature.
const { blockhash, lastValidBlockHeight: newHeight } =
await connection.getLatestBlockhash("confirmed");
tx.message.recentBlockhash = blockhash;
tx.sign([signer]);
return submitUntilLanded(connection, tx.serialize(), newHeight);
}
Rebuilding produces a different signature, so it is genuinely a new transaction. That is safe here precisely because the old one can no longer be included.
★If you rebuild before the old one expired, both could land.★ Only rebuild after confirming expiry.
Idempotence: Why Resending Is Safe
A frequent worry, worth settling.
Identical signed bytes produce an identical signature, and Solana includes any given signature at most once. Sending the same transaction fifty times results in at most one execution.
★What is not safe is rebuilding with a fresh blockhash while the original is still valid.★ That is two different signatures for the same intent, and both can land. Rebuild only after expiry.
Choosing
What failed?
├─ Not included yet, still inside the window → ★one attempt per slot★
├─ 429 or transport error → ★exponential backoff + jitter★
├─ Blockhash expired → ★rebuild, do not resend★
└─ Landed with an error → ★do not retry — fix it★
Most retry bugs come from applying the wrong row.
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
★Most transactions land well inside the validity window, which means retries are a safety net rather than the mechanism.★ If you routinely need many attempts, the cause is fee or routing — retries are compensating for something else.
Where BoltTx Fits
We handle routing, which is the reason most of those retries exist.
Submissions go 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. Your retry loop works the same way against our endpoint as any other.
You sign locally. We never hold funds, never sign, and never modify transaction contents. 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");
Then count attempts per landed transaction and see whether it drops.
FAQ
How should I retry a Solana transaction? Resubmit the same signed bytes roughly once per slot until it lands or the blockhash expires. Do not back off — each attempt is a fresh chance at a new block producer, and the window is already short.
Is it safe to resend the same Solana transaction? Yes. Identical bytes produce an identical signature, and Solana includes any signature at most once. Sending it repeatedly results in at most one execution.
What retry interval should I use for sending? About one slot. Faster wastes bandwidth on the same producer; slower skips chances. A two-second interval spends most of your validity window doing nothing.
Should I use exponential backoff for transaction submission? No. Backoff is for rate limits and transport errors, where the problem is asking too often. For inclusion, backing off means fewer attempts in a window that is already short.
When should I use exponential backoff on Solana?
For 429 responses and transport errors. Add jitter, because ten workers that all back off by the same amount recreate the identical spike that triggered the limit.
Why does jitter matter in a retry loop? Without it, every worker that received an error retries on the same tick. The retry itself becomes the next burst, and the rate limit never clears.
What should I do when the blockhash expires? Rebuild with a fresh blockhash and re-sign. Those old bytes are permanently invalid — resending them accomplishes nothing and will never land.
Can I rebuild a transaction before the blockhash expires? Not safely. Two different signatures for the same intent means both can land. Rebuild only after confirming the old one has expired.
Should I retry a transaction that landed with an error? No. It reached the chain and the instructions failed — slippage, balance, account state. Retrying reproduces the same failure and pays the base fee again. Fix the cause.
Why does maxRetries need to be zero? Because the RPC's built-in retry runs on a schedule you cannot see. With your loop and the RPC both resending on different clocks, behaviour becomes impossible to reason about or reproduce.
How do I know whether to retry or rebuild?
Compare current block height against lastValidBlockHeight. Inside the window, resend the same bytes. Past it, rebuild. This single check prevents most wasted retries.
How many retries are normal? Most transactions land within a couple of slots, so a handful of attempts is typical. If you routinely need many, the cause is fee or routing rather than something more retries will solve.
Does retrying cost me extra fees? Only if more than one attempt lands, which cannot happen with identical bytes. What does cost you is a transaction that lands and fails — that pays a base fee, and retrying it pays again.
Should I retry after a network timeout? Yes, with backoff. But first check the signature status, because the transaction may have been submitted successfully even though the response never arrived.
What is the difference between retrying and resubmitting? Resubmitting sends identical bytes with the same signature. Retrying in the general sense may mean rebuilding, which produces a new signature. The first is idempotent; the second is only safe after expiry.
Related Reading
- Solana Transaction Landing
- Why Solana Transactions Do Not Land
- Handling Blockhash Expiry
- Solana RPC Rate Limits
- Solana sendTransaction Best Practices