Sending tokens to ten thousand wallets is not ten thousand transfers. It is a distributed job with partial failure, a resume requirement, and a cost structure that surprises people the first time.
The mistake that causes the most damage is not a slow distribution. It is a distribution that is restarted after a crash and sends some recipients twice.
Push or Claim
Decide this before writing any code, because it determines everything after.
| Push | Claim | |
|---|---|---|
| Who pays fees | ★you★ | recipient |
| Who pays rent | ★you★ | recipient |
| Cost scales with | recipients | ★claimers★ |
| Inactive wallets | ★still cost you★ | cost nothing |
| Complexity | batching job | on-chain program |
★Push distribution pays for every recipient, including the majority who may never interact with the token.★ For a large list where engagement is uncertain, a claim model — typically a Merkle proof against an on-chain root — moves that cost to people who actually want the tokens.
Push is right for small lists, known-active recipients, and cases where you want the tokens to arrive without action. Claim is right at scale. Choosing push because it is simpler, then discovering the cost at ten thousand recipients, is the common path.
The rest of this assumes push, since that is where the transaction mechanics live.
The Token Account Problem
This is what makes airdrops different from SOL transfers. You cannot send an SPL token to a wallet address. You send it to that wallet's associated token account for your mint — and for most recipients, that account does not exist.
Creating it costs rent, paid by whoever sends the transaction:
import {
getAssociatedTokenAddress,
createAssociatedTokenAccountIdempotentInstruction,
createTransferInstruction,
} from "@solana/spl-token";
const ata = await getAssociatedTokenAddress(mint, recipient);
const ixs = [
// ★Idempotent: succeeds whether or not it already exists.★
createAssociatedTokenAccountIdempotentInstruction(
payer.publicKey, ata, recipient, mint,
),
createTransferInstruction(sourceAta, ata, payer.publicKey, amount),
];
★Use the idempotent variant.★ The plain createAssociatedTokenAccount instruction fails if the account exists — which reverts the whole batch because the transaction is atomic. One recipient who already holds the token kills twenty transfers.
Budget the rent honestly. Price it with getMinimumBalanceForRentExemption before you start: at ten thousand recipients who mostly lack the account, creation is a meaningful line item — often larger than the transaction fees. It is recoverable only if the recipient later closes the account, which for practical purposes means it is spent.
If your distribution job is correct and throughput is the problem, a free BoltTx key is one line to test the submission path.
Batch Sizing
Two instructions per recipient, and two ceilings:
1232 bytes. Each recipient contributes their wallet, their token account, and the amount. ★Account references dominate, so this usually binds first.★
Compute units. Account creation is not cheap, so a batch of creates plus transfers costs considerably more than transfers alone.
// ★Measure both, do not assume.★
const sim = await connection.simulateTransaction(tx, {
replaceRecentBlockhash: true, sigVerify: false,
});
console.log("bytes:", tx.serialize().length, "/ 1232");
console.log("units:", sim.value.unitsConsumed);
In practice this lands in the range of a handful to a dozen recipients per transaction when accounts must be created, and more when they already exist. ★Address lookup tables help, since the mint, token program, and payer repeat in every batch.★
Sort recipients by whether their token account exists. Batches of pure transfers fit far more recipients than batches that also create accounts, so separating them raises your average batch size.
Resumability Is the Requirement
A ten thousand recipient job will be interrupted. Design for that from the start rather than adding it after the first incident.
// ★Persist before sending, not after.★
for (const batch of batches) {
const batchId = hash(batch.recipients);
if (await isComplete(batchId)) continue;
await recordAttempt(batchId, batch); // ★before★
const sig = await send(batch);
await recordSignature(batchId, sig);
const outcome = await resolve(sig, lastValidBlockHeight);
await recordOutcome(batchId, outcome);
}
★The ordering matters more than it looks.★ If you record only after success, a crash between sending and recording leaves a batch that landed but is marked incomplete — and the restart sends it again.
On restart, resolve unknown signatures before resending. A batch with a recorded signature and no outcome is exactly the ambiguous case: check the signature status first, and only rebuild if it genuinely never landed.
const prior = await connection.getSignatureStatus(recordedSig,
{ searchTransactionHistory: true }); // ★restart: the status cache has aged out★
if (prior.value && !prior.value.err) {
await recordOutcome(batchId, { status: "success" });
continue; // ★do not resend★
}
★Resending an identical signed transaction is safe. Rebuilding is not.★ A rebuilt transaction has a new signature and will execute a second time if the first one landed unobserved.
Verify Against the Chain, Not Your Log
When the job reports complete, your log says what you believe happened. ★Verify against the chain before telling anyone it is done.★
// Independent reconciliation.
const holders = await connection.getProgramAccounts(TOKEN_PROGRAM_ID, {
filters: [
{ dataSize: 165 },
{ memcmp: { offset: 0, bytes: mint.toBase58() } },
],
});
Compare that set against your intended list. The discrepancies are what matter — recipients you believe you paid who hold nothing, or recipients holding more than intended, which points at a double-send.
This reconciliation is also what lets you answer the question you will be asked: "I did not receive mine." Without it, you are quoting your own log back at someone, which is not evidence.
Cost Before You Commit
Estimate on a real sample rather than a spreadsheet guess:
// Run one real batch, then extrapolate.
const perBatch = {
baseFee: 5000, // one signature
priorityFee: measuredPriorityFee,
rent: newAccounts * rentExemptAmount, // ★usually the largest★
};
★Rent for new token accounts typically dominates.★ Teams budget for transaction fees, find them small, and are surprised by the account creation cost — which is the actual reason claim models exist at scale.
Also budget for reverts. A batch that fails partway still costs the fee, and a large distribution will have some.
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
★For a distribution job, the tail sets your total runtime.★ Batches that expire must be rebuilt and resent, so a wide tail turns a job you sized in minutes into one that runs considerably longer.
Where BoltTx Fits
We handle submission. Not your recipient list, your batching, or your claim program.
Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure. For a distribution job the useful property is a consistent distribution, which is what makes total runtime predictable rather than dependent on network conditions.
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");
FAQ
How do I airdrop tokens to thousands of Solana wallets? Batch several recipients per transaction, persist progress before sending, and resolve every batch to a terminal state. The hard requirements are resumability and not double-sending after a crash.
How many recipients fit in one Solana transaction? Usually a handful to a dozen when token accounts must be created, and more when they already exist. Account references at 32 bytes each hit the 1232-byte limit before compute usually does.
Why does my airdrop transaction fail for some recipients?
Most often because a token account already exists and you used the non-idempotent create instruction, which reverts the whole atomic batch. Use createAssociatedTokenAccountIdempotentInstruction.
Who pays for token accounts in an airdrop? Whoever sends the transaction. In a push distribution that is you, for every recipient who does not already have the account, and it is usually the largest cost in the job.
Should I use push or claim distribution? Claim at scale, since it moves fee and rent cost to people who actually want the tokens. Push suits small lists, known-active recipients, and cases where arrival without action matters.
How do I resume an airdrop after a crash? Persist each batch before sending, record its signature, and on restart resolve any signature with no recorded outcome. Resend identical bytes if it never landed, and never rebuild without checking first.
Is it safe to resend an airdrop transaction? Identical signed bytes are safe, since one signature is included at most once. Rebuilding is not safe, because a new signature can execute a second time if the original landed unobserved.
How much does a Solana airdrop cost? Base fee per transaction, plus priority fee, plus rent for every token account created. Measure on a real batch and extrapolate, since the rent component usually dominates the fees.
How do I verify an airdrop completed correctly? Reconcile against the chain rather than your own log. Query token accounts for the mint and compare holders against your intended list — the discrepancies are the whole point of the exercise.
What happens if a recipient address is invalid? The instruction fails and the entire atomic batch reverts. Validate addresses while building the list, since one bad address costs you the whole batch and the fee.
Should I sort recipients before batching? Yes, by whether their token account already exists. Batches of pure transfers fit more recipients than batches that also create accounts, so separating them raises your average batch size.
Can I run airdrop batches in parallel? Yes, with bounded concurrency. They all write the same source token account, though, so contention on that account limits how much parallelism actually helps.
How long does a large airdrop take? It depends on batch size and how much of the tail expires and needs resending. Estimate from a real sample rather than from an ideal case, since expired batches are the usual reason a job overruns.
Do I need address lookup tables for an airdrop? They help, since the mint, token program, and payer repeat in every batch. The recipients differ each time, so the saving is smaller than for a bot with a fixed account set but still worthwhile.
What if a recipient closes their token account after the airdrop? They recover the rent you paid. That is expected behaviour and is one reason push distribution to unengaged recipients is expensive — you fund accounts that may be immediately closed.
How do I handle recipients who already hold the token? The idempotent create instruction handles it — it succeeds whether the account exists or not, so a mixed batch of new and existing holders works without special-casing.
Related Reading
- Sending Solana Transactions in Batches
- Solana Associated Token Account
- Solana Account Rent Exemption
- High-Frequency Small Transactions on Solana
- Solana Transaction Landing