Most trading bots start with one RPC URL in a config file and grow from there. It works until the bot matters, and then two problems appear at once: you hit rate limits on reads, and you start losing races on sends.
Those are different problems with different fixes, and trying to solve both with a bigger plan usually fixes neither.
The Split
A trading bot does three things with an RPC, and they want incompatible properties:
| Workload | Wants | Example calls |
|---|---|---|
| Read state | throughput, generous limits | getAccountInfo, getMultipleAccounts |
| Stream events | push delivery, filtering | onLogs, onAccountChange |
| Send transactions | ★short path, stake weight★ | sendRawTransaction |
Reads want to be cheap in bulk. Sends want to be fast once. ★An endpoint tuned for one is compromised for the other★, which is why most production setups end up splitting them.
// Reads and streaming: whatever provider fits your volume.
const reader = new Connection(READ_RPC, {
commitment: "confirmed",
wsEndpoint: READ_WS,
});
// Sending: a separate endpoint, chosen for submission behaviour.
const sender = new Connection(SEND_RPC, { commitment: "processed" });
The two integration points are independent, so you can change one without touching the other. That alone is worth the split, because it lets you test a send endpoint against real traffic without risking your read path.
If you have already split them and want a send endpoint to benchmark, a free BoltTx key is one line.
Read Configuration
Three settings that matter more than the tier you pick.
Batch instead of loop. The single largest reduction in read volume in most codebases:
// Expensive: N requests.
// const infos = await Promise.all(pubkeys.map((pk) => reader.getAccountInfo(pk)));
// Cheap: one request per 100 accounts.
const infos = await reader.getMultipleAccountsInfo(pubkeys);
Commitment level per call, not globally. Price checks can be processed; anything you record as truth should be confirmed.
Connection reuse. A fresh TLS connection per request means a handshake before any data moves. Reuse removes it entirely, and on a warm connection HTTPS costs essentially the same as plain HTTP.
import { Agent } from "undici";
const agent = new Agent({ keepAliveTimeout: 60_000, connections: 8 });
Send Configuration
This is where the settings are counterintuitive, because the defaults are wrong for trading.
const sig = await sender.sendRawTransaction(raw, {
skipPreflight: true, // ★default is false★
maxRetries: 0, // ★default is not zero★
});
skipPreflight: true. Preflight simulates against the current slot and costs a round trip. But you will land in a later slot against different state, so a passing simulation predicts nothing while spending latency you needed. Simulate during development, where it is genuinely useful.
maxRetries: 0. The RPC's built-in retry runs on a schedule you cannot observe. If you are also retrying, two components resend on different clocks and behaviour becomes impossible to reason about. Drive retries yourself, because only you know when the blockhash expires.
Then the loop that actually works:
while (await sender.getBlockHeight("confirmed") <= lastValidBlockHeight) {
const { value } = await sender.getSignatureStatuses([sig]);
if (value[0]) break; // landed; check .err
await sender.sendRawTransaction(raw, { skipPreflight: true, maxRetries: 0 });
await new Promise((r) => setTimeout(r, 400)); // roughly one slot
}
Resending identical bytes is safe — same signature, included at most once.
Blockhash Handling
The setting that silently costs the most.
// Refresh in the background; never fetch in the hot path.
let cached = await reader.getLatestBlockhash("confirmed");
setInterval(async () => {
cached = await reader.getLatestBlockhash("confirmed");
}, 5_000);
★A blockhash fetch between "I decided to trade" and "I submitted" is a round trip you cannot afford.★ Caching removes it. Refreshing on a timer keeps it fresh enough that you are never near expiry when it matters.
Track how much of the validity window you have already spent — this is the metric almost nobody logs:
log({ blockhashAge: Date.now() - cachedAt });
Fees
Hardcoded priority fees are wrong in both directions: wasteful when the network is quiet, insufficient when it is not.
import { ComputeBudgetProgram } from "@solana/web3.js";
// Fees are contested per account, not globally.
const recent = await reader.getRecentPrioritizationFees({
lockedWritableAccounts: writableAccounts,
});
const fees = recent.map((r) => r.prioritizationFee).sort((a, b) => a - b);
const median = fees[Math.floor(fees.length / 2)] ?? 0;
instructions.unshift(
ComputeBudgetProgram.setComputeUnitPrice({
microLamports: Math.max(median * 2, 5_000),
}),
ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }),
);
★Set the compute unit limit.★ Without one you are charged against a default well above your real usage, wasting budget precisely when fees are high.
What to Monitor
Three counters, tracked separately. Collapsing them into one success rate hides which problem you have:
const { value } = await sender.getSignatureStatuses([sig], {
searchTransactionHistory: true,
});
if (!value[0]) metrics.inc("never_landed"); // ★fee, path, retry, expiry★
else if (value[0].err) metrics.inc("landed_failed"); // ★slippage, balance, state★
else metrics.inc("landed_ok");
Plus slot distance from submit to land, bucketed by network condition. ★A landing rate that holds when calm and falls under load points at routing★, which is the one thing you cannot fix in application code.
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
If your own distribution is materially wider, work through fees, blockhash age, and retry behaviour first — then look at the path.
A Minimal Config That Works
// Reads + streaming
const reader = new Connection(READ_RPC, {
commitment: "confirmed",
wsEndpoint: READ_WS,
});
// Sends
const sender = new Connection(SEND_RPC, { commitment: "processed" });
// Background blockhash refresh
let blockhash = await reader.getLatestBlockhash("confirmed");
setInterval(async () => {
blockhash = await reader.getLatestBlockhash("confirmed");
}, 5_000);
// Per-send: derived fee, CU limit, skipPreflight, own retry loop
That is the whole shape. Everything else is tuning within it.
Where BoltTx Fits
We are the send half. Not indexing, not parsed history, not streaming.
Submissions route 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. Keep your existing read provider — the integration points are independent.
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 sender = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
FAQ
What RPC setup does a Solana trading bot need? Usually two endpoints: one for reads and streaming, one for sending. The workloads want different properties, and the integration points are independent so you can change either without touching the other.
Should I use one RPC or two for my trading bot? Two, once the bot matters. Reads want throughput and generous limits; sends want a short path to a block producer with stake behind it. One endpoint tuned for both is compromised on each.
What commitment level should a trading bot use?
Per call rather than globally. processed for price checks and event detection, confirmed for anything you record as truth. A single global setting forces the wrong trade-off somewhere.
Why should skipPreflight be true for a trading bot? Preflight costs a network round trip and simulates against the current slot, not the slot you will land in. It can pass and tell you nothing while spending latency. Simulate during development instead.
Why set maxRetries to zero? Because the RPC's built-in retry runs on a schedule you cannot see. If you are running your own loop, two components resending on different clocks makes behaviour unpredictable. Only your loop knows when the blockhash expires.
How do I reduce RPC usage in a trading bot?
Batch with getMultipleAccounts rather than looping getAccountInfo, replace polling with subscriptions where the data is push-friendly, and filter program subscriptions server-side.
Should I cache blockhashes? Yes, refreshed on a background timer. Fetching one between deciding and submitting adds a round trip in the one place you cannot afford it. Also log how much of the validity window you have already spent.
What priority fee should a trading bot use?
Derive it from getRecentPrioritizationFees on the accounts your transaction writes, then scale for how contested that moment is. A hardcoded value is wasteful when quiet and insufficient when busy.
Why is my bot's success rate misleading? Because it merges two unrelated problems. Track never-landed and landed-but-failed separately: the first is fee, path, retry, or expiry; the second is slippage, balance, or account state.
Do I need a paid RPC for a Solana trading bot? For reads at low volume, sometimes not. For sending during congestion, shared public endpoints are the worst case, since they are busiest at exactly the moments your trades matter.
How do I test whether a new send endpoint is better? Send alternating transactions through both for at least a week, bucketed by network condition, and compare landing rate and slot distance. Testing only during calm periods measures the case where every path performs the same.
What compute unit limit should I set? Simulate during development to find real consumption and set slightly above it. Leaving the default means being charged against a much higher figure, which wastes budget when fees spike.
Does connection reuse matter for a trading bot? Yes. A fresh TLS connection requires a handshake before any bytes move. On a reused connection that cost is gone, and for a bot sending continuously it is latency you would otherwise pay every time.
Should reads and sends use the same commitment?
No. Sends benefit from processed for speed in the confirmation loop; reads that feed your database should use confirmed or better, since processed results can be rolled back.
What metrics tell me my RPC setup is the bottleneck? Landing rate split by network condition. If it holds when the network is calm and drops under load, with live fee calculation and fresh blockhashes, the remaining variable is the submission path.