Sending one transaction is well documented. Sending two hundred in the same minute is where most bots discover their architecture was built for the first case.
The failure is rarely a crash. It is a slow degradation: the first thirty land, the next fifty are slow, and the last hundred expire.
Sequential Is the Wrong Default
The obvious loop is also the slowest one:
// ★Do not do this.★
for (const tx of transactions) {
await connection.sendRawTransaction(tx.serialize());
}
Each iteration waits for a network round trip before starting the next. With two hundred transactions you are serialising two hundred round trips, and your blockhash is expiring the whole time.
★The blockhash is the deadline you cannot negotiate.★ It stays valid for roughly 150 blocks. A sequential loop that outlasts that window will find its later transactions rejected for a reason that has nothing to do with them.
Bounded Parallelism
Unbounded parallelism is the other mistake. Firing two hundred concurrent requests at an RPC endpoint gets you rate limited, and rate-limited sends are worse than slow sends because they never reach the network at all.
async function sendBatch(txs, connection, concurrency = 10) {
const results = [];
const queue = [...txs];
const workers = Array.from({ length: concurrency }, async () => {
while (queue.length) {
const tx = queue.shift();
if (!tx) break;
try {
const sig = await connection.sendRawTransaction(tx.serialize(), {
skipPreflight: true,
maxRetries: 0,
});
results.push({ ok: true, sig });
} catch (e) {
results.push({ ok: false, error: e });
}
}
});
await Promise.all(workers);
return results;
}
★The worker-pool shape matters more than the concurrency number.★ It keeps exactly N requests in flight regardless of how long individual sends take, which a chunked Promise.all does not do — that one waits for the slowest member of each chunk before starting the next.
skipPreflight: true is close to mandatory in a batch. Preflight doubles your request count, and simulating two hundred transactions against the current slot tells you very little about the slot they will land in.
maxRetries: 0 stops the RPC from running its own retry schedule underneath your loop. Two components resending on different clocks makes batch behaviour impossible to reason about.
If your batch is tuned and the tail still expires, a free BoltTx key is one line of config to test against.
Sharing One Blockhash
Every transaction in a batch can share a blockhash, and usually should:
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("confirmed");
const signed = payloads.map((p) => {
const tx = buildTransaction(p, blockhash);
tx.sign([payer]);
return tx;
});
One fetch rather than two hundred. ★But it also means the whole batch shares one deadline.★ If your build-and-sign loop is slow, and signing two hundred transactions is not free, you are burning window before anything is sent.
// ★Measure how much window you spent preparing.★
const startHeight = await connection.getBlockHeight();
const signed = buildAll(payloads, blockhash);
const readyHeight = await connection.getBlockHeight();
console.log(`prep cost ${readyHeight - startHeight} blocks of ~150`);
If preparation costs more than a handful of blocks, sign in parallel with sending rather than in a phase before it.
Same Signer, Different Transactions
A batch from one wallet has a constraint that is easy to miss: transactions from the same fee payer are independent, not ordered.
Solana does not guarantee execution order for separately submitted transactions. Two transactions that both modify the same account may land in either order, or in the same block in an order you did not choose.
★If the operations must happen in sequence, they cannot be a batch.★ Either combine them into one transaction as multiple instructions, or send them as a chain where each waits for the previous to confirm.
When batching is correct:
- Distributions to distinct recipients
- Independent trades across unrelated markets
- Closing many accounts that do not interact
When it is not:
- Anything with a required order
- Operations that share a mutable account and would conflict
- Multi-step flows where step two depends on the result of step one
Rate Limits Are the Real Ceiling
Most public RPC endpoints limit requests per second, and a batch is the fastest way to find that limit.
async function sendWithLimitAwareness(tx, connection) {
try {
return await connection.sendRawTransaction(tx.serialize(), {
skipPreflight: true,
maxRetries: 0,
});
} catch (e) {
const msg = String(e?.message ?? e);
if (msg.includes("429") || msg.toLowerCase().includes("rate")) {
// ★Backoff belongs here — and only here.★
await sleep(jitteredDelay());
return sendWithLimitAwareness(tx, connection);
}
throw e; // Not a rate limit; rebuilding is likelier to help than retrying.
}
}
★Backoff for rate limits, no backoff for inclusion.★ These are opposite behaviours, and conflating them is the most common batching bug: a bot that backs off on inclusion failures makes fewer attempts inside a window it cannot extend.
Confirming a Batch
Polling each signature separately turns a two hundred transaction batch into two hundred polling loops. getSignatureStatuses takes up to 256 signatures at once:
async function confirmBatch(sigs, connection, lastValidBlockHeight) {
const pending = new Set(sigs);
const landed = new Map();
while (pending.size) {
const height = await connection.getBlockHeight();
if (height > lastValidBlockHeight) break; // ★expired: stop, rebuild★
// ★rotate: slicing the same head every pass starves the tail★
const all = [...pending];
const batch = all.splice(0, 256);
all.forEach((s) => { pending.delete(s); pending.add(s); });
const { value } = await connection.getSignatureStatuses(batch);
batch.forEach((sig, i) => {
const st = value[i];
if (st?.confirmationStatus) {
landed.set(sig, st.err ? "reverted" : "success");
pending.delete(sig);
}
});
await sleep(400); // roughly one slot
}
return { landed, expired: [...pending] };
}
★Three outcomes, not two.★ A transaction can succeed, land and revert, or never land at all. A batch report that only counts signatures returned by sendRawTransaction is reporting submissions, not results.
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
★In a batch, the slowest member matters more than the median.★ Your batch is only finished when its slowest member lands, so a wide tail costs you the whole window rather than one transaction.
Where BoltTx Fits
Batching is your side. Routing is ours.
Submissions go through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so transactions are not observable in transit before they land. Your batching logic does not change — only the endpoint you send to.
You sign locally. We never hold funds, never sign, and never modify transaction contents. The tip travels inside each 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
How do I send multiple Solana transactions at once? With bounded parallelism — a worker pool that keeps a fixed number of sends in flight. A sequential loop serialises round trips and burns your blockhash window, while unbounded parallelism gets you rate limited.
How many transactions can I send in parallel? That depends on your endpoint rate limit rather than on Solana. Start around ten concurrent sends, watch for 429 responses, and raise it until you see them.
Can transactions in a batch share one blockhash? Yes, and usually they should — one fetch instead of many. But the whole batch then shares one expiry deadline, so preparation time comes out of the same window.
Are batched Solana transactions executed in order? No. Separately submitted transactions have no guaranteed ordering, even from the same fee payer. If order matters, combine the operations into one transaction or chain them with confirmation between.
Why do the later transactions in my batch fail? Usually blockhash expiry. A slow sequential loop spends the validity window before reaching the end of the list, so the last transactions are rejected for a reason unrelated to their contents.
Should I use skipPreflight when batching? Almost always. Preflight doubles the request count and simulates against the current slot rather than the one you will land in. Simulate during development instead.
How do I confirm many signatures efficiently?
getSignatureStatuses accepts up to 256 signatures per call. Poll the whole set at roughly one-slot intervals and remove signatures as they resolve, rather than running a loop per transaction.
What is the right retry strategy for a batch? Backoff for rate limits only. For inclusion failures, resend at about one-slot intervals without backoff, because each attempt is a fresh chance at a new block producer inside a window you cannot extend.
Can I batch transactions from different wallets? Yes, and it often helps, since per-account contention spreads out. Each transaction still needs its own signature from its own fee payer.
How do I avoid rate limits when batching?
Cap concurrency, use skipPreflight to halve your request count, and batch your confirmation polling. Most rate-limit problems in batches come from preflight and per-signature polling rather than from the sends themselves.
Should batch failures be retried individually or as a batch? Individually. A batch usually fails partially, and resending the whole set wastes attempts on transactions that already landed, while making your results harder to interpret.
What happens if a batch transaction lands but reverts? It consumed a base fee and did not change state. Treat it as a third outcome distinct from success and expiry, since the cause is in your instructions rather than in your sending.
Is it faster to batch instructions into one transaction instead? When the operations are related and fit within 1232 bytes, yes — one transaction means one signature, one fee, and guaranteed atomicity. Batching is for operations that are genuinely independent.
How do I know if my batch concurrency is too high? Watch the 429 rate alongside the share of transactions that expire. Rising rate limits with a flat expiry rate means you are past the useful concurrency and adding pressure without adding throughput.
Does batching increase my chance of landing? Not per transaction. It reduces total wall-clock time, which leaves more of the validity window for retries. The landing behaviour of each individual transaction is unchanged.
How long does a batch have before it expires?
Roughly 150 blocks from the blockhash you signed with. Compare getBlockHeight() against the lastValidBlockHeight returned alongside it rather than using a wall-clock timer.