Send, Confirm, Retry and Lifetime Management
Send a signed transaction the right way, separate sending from confirmation, and retry only when rebuilding is actually safe.
The biggest transaction UX bug is simple.
Apps act like submitted means done.
It does not.
A transaction can be signed, have a valid signature, and still fail to confirm.
It can also expire before the network accepts it.
That is why the send flow has to separate three different ideas:
- the transaction already has a signature
- the transaction has been sent to the network
- the transaction has been confirmed at the commitment you care about
If you collapse those into one step, your retry logic will be wrong.
The Signature Exists Before Sending
This matters more than most beginners expect.
With Kit, the transaction signature is available as soon as the transaction is signed by the fee payer.
You do not need to wait for the RPC node to return it.
That means:
- you can display the signature immediately
- you can log it before send
- you can still fail later during send or confirmation
That is the right model.
Step 1: Fund The Sender Before You Test Retries
The old version of this lesson had a real bug.
It generated a fresh sender and tried to transfer lamports immediately.
That script could not succeed.
This version fixes that first.
Create scripts/send-confirm.ts:
import {
airdropFactory,
appendTransactionMessageInstructions,
createSolanaRpc,
createSolanaRpcSubscriptions,
createTransactionMessage,
generateKeyPairSigner,
getSignatureFromTransaction,
lamports,
pipe,
sendAndConfirmTransactionFactory,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";
const RPC_URL = "http://127.0.0.1:8899";
const RPC_WS_URL = "ws://127.0.0.1:8900";
const LAMPORTS_PER_SOL = 1_000_000_000n;
type GeneratedSigner = Awaited<ReturnType<typeof generateKeyPairSigner>>;
const rpc = createSolanaRpc(RPC_URL);
const rpcSubscriptions = createSolanaRpcSubscriptions(RPC_WS_URL);
async function prepareActors(): Promise<{
sender: GeneratedSigner;
recipient: GeneratedSigner;
}> {
const sender = await generateKeyPairSigner();
const recipient = await generateKeyPairSigner();
await airdropFactory({ rpc, rpcSubscriptions })({
recipientAddress: sender.address,
lamports: lamports(LAMPORTS_PER_SOL),
commitment: "confirmed",
});
return { sender, recipient };
}
async function buildAndSign(sender: GeneratedSigner, recipient: GeneratedSigner) {
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
const transferInstruction = getTransferSolInstruction({
source: sender,
destination: recipient.address,
amount: lamports(LAMPORTS_PER_SOL / 100n),
});
const transactionMessage = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(sender, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([transferInstruction], tx),
);
return signTransactionMessageWithSigners(transactionMessage);
}
async function main() {
const { sender, recipient } = await prepareActors();
const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({
rpc,
rpcSubscriptions,
});
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const signedTransaction = await buildAndSign(sender, recipient);
const signature = getSignatureFromTransaction(signedTransaction);
console.log("submitted", signature);
await sendAndConfirmTransaction(signedTransaction, {
commitment: "confirmed",
});
console.log("confirmed", signature);
return;
} catch (e: any) {
const message = String(e?.message ?? e);
const retryable =
message.includes("Blockhash") ||
message.includes("timeout") ||
message.includes("expired");
if (!retryable || attempt === 2) {
throw e;
}
console.log("retrying with a freshly rebuilt transaction...");
}
}
}
main().catch((e) => {
console.error("send/confirm failed", e);
process.exit(1);
});Run it with:
pnpm tsx scripts/send-confirm.tsprepareActors() funds the sender before any transfer is built, so the retry loop is testing send and confirmation behavior instead of failing early for insufficient funds.
That distinction matters.
If the example is going to teach retry policy, it has to survive the first send.
Read The Flow In The Right Order
First, rebuild before every retry
This is the real rule.
A retry is not send the same bytes again forever.
If the blockhash lifetime is stale, the correct retry path is:
- fetch a fresh blockhash
- rebuild the message
- sign again
- send again
That is why the helper above calls buildAndSign(sender, recipient) again inside the retry loop.
Then separate submission from confirmation
const signature = getSignatureFromTransaction(signedTransaction);
console.log("submitted", signature);
await sendAndConfirmTransaction(signedTransaction, {
commitment: "confirmed",
});
console.log("confirmed", signature);That is the correct distinction.
The signature exists before confirmation.
Confirmation is about network outcome, not identity.
Then keep retry policy explicit
This line matters:
const retryable =
message.includes("Blockhash") ||
message.includes("timeout") ||
message.includes("expired");The point is not perfect string parsing.
The point is policy.
Some failures deserve rebuild-and-retry.
Some failures do not.
User rejection is not retryable.
Program logic errors are not retryable until inputs or state change.
The Failure Modes To Avoid
Retrying stale signed bytes
That is one of the most common client mistakes.
If the lifetime expired, you need a new message.
Reporting confirmed too early
Users need honest state.
If the app only knows the transaction was submitted, say that.
Do not claim success before the confirmation path finishes.
Treating every error as retryable
That turns real bugs into noisy loops.
Retry only when the failure mode actually supports retry.
What You Can Do After This
You can separate signature, submission, and confirmation correctly, and you can retry only by rebuilding the transaction when the lifetime requires it.
That is the baseline for reliable client-side transaction delivery.
The next lessons build on that for token flows and broader client testing.
Quick Check
Why can the client show a transaction signature before the transaction is confirmed?
Why is rebuilding the transaction the correct retry path after a blockhash lifetime issue?
Up Next in Solana Kit Clients
SPL Token and Token-2022 Client Flows
Apply the same transport discipline to token actions by validating program choice, mint, token accounts, and decimals before the wallet prompt opens.