An RPC timeout does not mean your Solana transaction failed.
The node may have forwarded it and lost the response. Treating every timeout as permission to rebuild can put a second valid transaction beside the first.
The safe unit of retry is the signed transaction. Re-sign only after you can prove the original blockhash expired.
Checked against the Solana RPC documentation on July 22, 2026.
Kill the default provider rotation
A common failover loop rotates endpoints around one high-level send function:
for (const rpc of providers) {
try {
return await buildSignAndSend(rpc, intent);
} catch {
// Try the next provider with a fresh transaction.
}
}Each attempt may fetch a new blockhash and produce a new signature. Solana sees different transaction messages, even when your business intent is identical. Both can be accepted while both blockhashes remain valid.
Provider failover should move signed bytes between transports. It should not recreate the economic action.
By the end you will have
- One signature shared across RPC send attempts
- A status path that survives provider timeouts
- A block-height gate before any re-sign
- A provider-health rule that detects lagging nodes
- The Signature-First Failover Loop for your runbook
Separate intent, transaction, and transport
Store three identifiers because they answer different questions:
| Identifier | Answers | Lifetime |
|---|---|---|
intentId | Which business action is this? | Permanent |
signature | Which signed transaction did we submit? | Until final outcome |
attemptId | Which provider call did we make? | One network request |
One intent can have several transport attempts. Before expiration, every attempt must carry the same serialized transaction and therefore the same signature.
Persist the original blockhash and lastValidBlockHeight returned with getLatestBlockhash. Do this before the first send, not after an endpoint fails.
Magnet: Signature-First Failover Loop
This loop keeps transport retry separate from transaction replacement:
type PendingTx = {
intentId: string;
signature: string;
wireTransaction: Uint8Array;
blockhash: string;
lastValidBlockHeight: bigint;
};
async function submitWithFailover(tx: PendingTx, providers: Rpc[]) {
for (const rpc of providers) {
const status = await rpc.getSignatureStatus(tx.signature);
if (status?.confirmationStatus) return status;
const height = await rpc.getBlockHeight("confirmed");
if (height > tx.lastValidBlockHeight) return { expired: true };
try {
await rpc.sendTransaction(tx.wireTransaction, {
maxRetries: 2n,
preflightCommitment: "confirmed",
});
} catch (error) {
recordTransportError(rpc, error);
}
}
return { pending: true };
}You should see: one intent ID, one signature, and several provider attempt IDs until the transaction confirms or its recorded block height expires.
The snippet is deliberately incomplete at the edges. Your RPC client types, persistence layer, and status response differ. The invariant does not: do not call the signer inside the provider loop.
A timeout becomes pending, not failed
sendTransaction returning a signature means the RPC accepted the request. It does not prove that a leader processed the transaction. A thrown timeout proves even less because the request may have reached the node before the connection broke.
Model the submission state explicitly:
built → signed → submitted → confirmed → finalized
│
├─ transport_unknown
├─ execution_failed
└─ expiredOnly execution_failed and expired are terminal failures. transport_unknown belongs in the pending path, where you query the signature and rebroadcast the same bytes.
Use getSignatureStatuses for the recent status path. Older lookups can enable searchTransactionHistory, which expands the search beyond the recent status cache but costs more. Once confirmed, getTransaction can supply the transaction and metadata at your chosen commitment.
Keep blockhash and preflight commitments aligned
Solana's confirmation guide documents a subtle RPC-pool failure: one backend can return a blockhash that another, lagging backend does not know yet. The second node may reject or drop the transaction as "blockhash not found."
Use the same commitment when you fetch the blockhash, simulate, send preflight, and monitor expiration. If you fetched at confirmed, set preflightCommitment: "confirmed".
minContextSlot can keep a provider from serving data older than a slot your application already observed. It is a consistency floor, not a request to wait for that slot. Record the highest trusted context slot and reject providers that repeatedly cannot meet it.
Re-sign only after expiration
There are two retry operations, and mixing them is the dangerous part.
Rebroadcast: send the same signed bytes again. The signature stays the same, and the network's duplicate protection keeps it one transaction.
Replace: fetch a new blockhash, rebuild, and sign a new message. The signature changes. The old and new transactions can both land if the original is still valid.
Before replacement, check the recorded lastValidBlockHeight against a healthy provider's current block height. You can also call isBlockhashValid at the matching commitment. Require expiration plus a final signature-status check, then let the business-intent idempotency gate decide whether replacement is allowed.
Never use a wall-clock timeout as proof of expiration. Slot and block timing varies.
Score providers by role
One ordered provider list is too crude for production. Separate the jobs:
- Read provider: account data and historical queries
- Build provider: recent blockhash and simulation from a current node
- Send providers: bounded rebroadcast to transaction-focused endpoints
- Status providers: independent signature and block-height checks
The same provider may fill several roles, but measure each role separately. A node can answer account reads while lagging too far for safe blockhash work.
Track context slot lag, request latency, error class, preflight rejection, confirmation time, and landing rate. Health should decay gradually and recover after a clean probation window. One timeout should not permanently eject a provider; repeated stale-context responses should.
For production routing, RPC Edge is the natural infrastructure path in the Builderz stack. Evaluate it with the same role-level metrics rather than treating any provider name as proof of reliability.
Test the ugly paths on devnet
Happy-path tests do not exercise failover. Run these cases with a disposable devnet wallet:
- Drop the HTTP response after the node receives
sendTransaction. - Make the primary endpoint time out and confirm the backup receives identical bytes.
- Return a stale block height from one status provider.
- Keep the original pending until its blockhash expires, then replace it once.
- Confirm the intent ledger refuses a second replacement after success.
The useful assertion is not "the call returned." Assert that one intent produced no more than one confirmed economic effect.
Failure modes
| Failure | Symptom | Guard |
|---|---|---|
| Re-sign inside retry loop | Two valid signatures for one intent | Sign once before transport attempts |
| Timeout marked failed | UI invites the user to submit again | Persist transport_unknown as pending |
| Stale backup RPC | blockhash not found after rotation | Matching commitment and context-slot floor |
| Wall-clock expiration | Replacement starts while original is valid | Compare lastValidBlockHeight |
| Status checked on one node | Provider outage looks like chain failure | Query an independent status provider |
| Unlimited rebroadcast | Request storms during congestion | Bounded retries, jitter, and attempt budget |
| Skip preflight everywhere | Useful signature and simulation errors disappear | Keep preflight on unless measured otherwise |
When not to add multi-provider failover
- Your application only performs read requests and can tolerate a retry message.
- Transaction volume is low enough that a manual recovery runbook is safer.
- You have no durable store for intent, signature, blockhash, and outcome.
- The team cannot alert on pending transactions and reconcile them later.
- A second provider would add operational complexity without a measured availability problem.
A single well-operated endpoint with explicit pending states beats a five-provider loop that rebuilds blindly.
Source notes and related paths
- Solana retrying transactions for rebroadcast and re-sign rules
- Solana transaction confirmation and expiration for blockhash validity and commitment alignment
- Solana
isBlockhashValidfor an explicit validity check - Solana
getSignatureStatusesfor recent status lookup - Build a Safe Solana Transaction Tool for AI Agents for intent authorization and signer isolation
- Monitor Solana Transactions Without Missing Events for live-plus-backfill confirmation
Bottom line
Fail over the transport first. Replace the transaction only after the original cannot land.
Your next action: add the Signature-First Failover Loop to one devnet write path and force a timeout after submission. Verify that every provider sees the same signature.