To reduce Solana RPC latency, first measure where the time goes: distance to the endpoint, connection setup, the cost of the method you call, and how far the node's view lags the chain. Then fix the largest contributor, usually by running closer to the node, reusing connections, calling cheaper methods, or switching from polling to a stream. A dedicated node or Shredstream helps only after those basics are in place.
Where Solana RPC latency comes from
An RPC call's latency is the sum of several stages, and each has a different fix.
Distance and region
A bot in Frankfurt calling a node in New York pays a transatlantic round trip on every call, no matter how fast the node is. Region is usually the largest fixed cost and the cheapest to fix: run your code in the endpoint's region, or pick an endpoint where your code already runs.
Connection setup
The first request to a host pays for DNS resolution, a TCP handshake and a TLS handshake before any JSON is sent. Later requests on a kept-alive connection skip all of that. Node's built-in fetch keeps connections alive by default, so a long-running process mostly pays this cost once. Short-lived processes (serverless functions, cron scripts, CLI tools that exit after one call) pay it every time. That is why the benchmark below reports the cold first request separately.
Method cost
getSlot and getLatestBlockhash are cheap. getProgramAccounts scans validator account state, and archival calls such as getTransaction, getBlock and getSignaturesForAddress read historical data. On Solana Tracker's shared RPC, archival methods and getProgramAccounts cost 10 credits per call, while standard calls cost 1. getProgramAccounts also has its own, lower rate limit. If your hot path calls a heavy method, a faster network will not fix it.
Commitment level
processed returns the node's most recent state, which can still be rolled back. confirmed returns state voted on by a supermajority of stake. finalized is the strongest level and sits furthest behind the chain tip. Choosing finalized where confirmed is enough adds delay to every read. Choosing processed gets you earlier data that you must be ready to see reverted.
Polling instead of push
A bot polling getAccountInfo every 500 ms sees a change on average 250 ms late, plus the round trip, and burns credits on requests that return nothing new. A subscription pushes the change when the node sees it.
Node load and freshness
On shared infrastructure your requests compete with other tenants. And two endpoints can answer equally fast while one reports a slot several slots behind the other. A fast response with stale data is not low latency for a trading bot.
Measure Solana RPC latency correctly
Most RPC benchmarks go wrong in the same few ways:
- Run from production. Measure from where your bot runs. A laptop on home Wi-Fi measures your ISP.
- Use percentiles, not averages. Report p50 for the typical request and p90/p99 for the stalls users notice. With 20 samples, p99 is just the maximum; collect 100 or more before reading anything into it.
- Separate cold from warm. Report the first request (DNS, TCP, TLS) on its own and keep it out of the warm statistics.
- Keep commitment equal. Compare endpoints at the same commitment level your app uses.
- Measure freshness. Call
getSloton every endpoint at the same moment and compare each result with the highest slot seen. The gap is slot lag. - Count failures. Report timeouts, HTTP 429s and JSON-RPC errors. Dropping them makes a throttled endpoint look fast.
- Interleave endpoints. Test candidates in the same rounds, not one after another, so time-of-day traffic affects all of them equally.
- Respect rate limits. A benchmark that exceeds your plan's requests per second measures 429 responses, not latency.
Benchmark script: p50, p90, p99 and slot lag
The script below compares endpoints with plain fetch JSON-RPC calls and no Solana SDK. It times getSlot and getLatestBlockhash, records slot lag against the highest slot seen in each round, applies a per-request timeout with AbortSignal.timeout, and masks api_key values in its output.
npm init -y && npm pkg set type=module
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts
# .env
# Full URL from https://www.solanatracker.io/account/shared-rpc
SOLANA_RPC_URL=https://rpc-mainnet.solanatracker.io/?api_key=
# Optional: comma-separated list to compare (overrides SOLANA_RPC_URL)
RPC_URLS=
SAMPLES=20
TIMEOUT_MS=5000
COMMITMENT=confirmed
// index.ts: compare Solana RPC endpoints by round-trip latency and slot freshness
export {}; // makes this file an ES module so top-level await type-checks
type Commitment = "processed" | "confirmed" | "finalized";
type LatestBlockhash = { context: { slot: number }; value: { blockhash: string; lastValidBlockHeight: number } };
function exit(message: string): never {
console.error(message);
process.exit(1);
}
const raw = process.env.RPC_URLS?.trim() || process.env.SOLANA_RPC_URL?.trim();
if (!raw) exit("Missing SOLANA_RPC_URL (or RPC_URLS). Copy it from https://www.solanatracker.io/account/shared-rpc");
const urls = [...new Set(raw.split(",").map((s) => s.trim()).filter(Boolean))];
urls.forEach((u, i) => {
// Never print the raw value: it contains the API key.
if (!URL.canParse(u) || !/^https?:$/.test(new URL(u).protocol)) exit(`Endpoint #${i + 1} is not a valid http(s) URL`);
});
const SAMPLES = Number(process.env.SAMPLES ?? 20);
const TIMEOUT_MS = Number(process.env.TIMEOUT_MS ?? 5000);
const COMMITMENT = (process.env.COMMITMENT ?? "confirmed") as Commitment;
if (!Number.isInteger(SAMPLES) || SAMPLES < 1 || SAMPLES > 1000) exit("SAMPLES must be an integer from 1 to 1000");
if (!Number.isInteger(TIMEOUT_MS) || TIMEOUT_MS < 100) exit("TIMEOUT_MS must be an integer of at least 100");
if (!["processed", "confirmed", "finalized"].includes(COMMITMENT)) exit("COMMITMENT must be processed, confirmed or finalized");
function redact(u: string): string {
const url = new URL(u);
if (url.searchParams.has("api_key")) url.searchParams.set("api_key", "***");
return url.toString().replaceAll("%2A", "*");
}
async function rpc<T>(url: string, method: string, params: unknown[]): Promise<{ ms: number; result: T }> {
const started = performance.now();
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = (await res.json()) as { result?: T; error?: { code: number; message: string } };
const ms = performance.now() - started;
// JSON-RPC errors come back with HTTP 200, so check the body too.
if (body.error) throw new Error(`RPC ${body.error.code}: ${body.error.message}`);
if (body.result === undefined) throw new Error("response has no result");
return { ms, result: body.result };
}
function pct(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
if (sorted.length === 0) return Number.NaN;
return sorted[Math.min(sorted.length, Math.max(1, Math.ceil((p / 100) * sorted.length))) - 1] ?? Number.NaN;
}
type Endpoint = { label: string; url: string; cold?: number; slotMs: number[]; hashMs: number[]; lags: number[]; errors: number; lastError?: string };
const endpoints: Endpoint[] = urls.map((url) => ({ label: redact(url), url, slotMs: [], hashMs: [], lags: [], errors: 0 }));
function recordError(e: Endpoint, error: unknown): void {
e.errors++;
e.lastError = error instanceof Error ? error.message : String(error);
}
const params = [{ commitment: COMMITMENT }];
// Cold request: DNS + TCP + TLS. Reported on its own, never mixed into the warm stats.
await Promise.all(
endpoints.map(async (e) => {
try {
e.cold = (await rpc<number>(e.url, "getSlot", params)).ms;
} catch (error) {
recordError(e, error);
}
}),
);
for (let i = 0; i < SAMPLES; i++) {
// Same moment on every endpoint, so the slot values are comparable.
const slots = await Promise.all(
endpoints.map(async (e) => {
try {
const { ms, result } = await rpc<number>(e.url, "getSlot", params);
e.slotMs.push(ms);
return result;
} catch (error) {
recordError(e, error);
return null;
}
}),
);
const highest = Math.max(...slots.filter((s): s is number => s !== null));
slots.forEach((slot, j) => {
if (slot !== null && Number.isFinite(highest)) endpoints[j]?.lags.push(highest - slot);
});
await Promise.all(
endpoints.map(async (e) => {
try {
e.hashMs.push((await rpc<LatestBlockhash>(e.url, "getLatestBlockhash", params)).ms);
} catch (error) {
recordError(e, error);
}
}),
);
await new Promise((resolve) => setTimeout(resolve, 400)); // stay under 5 requests/second per endpoint
}
const f = (n: number) => (Number.isFinite(n) ? n.toFixed(1) : "n/a");
console.table(
endpoints.map((e) => ({
endpoint: e.label,
"getSlot p50": f(pct(e.slotMs, 50)),
"getSlot p90": f(pct(e.slotMs, 90)),
"getSlot p99": f(pct(e.slotMs, 99)),
"blockhash p50": f(pct(e.hashMs, 50)),
"blockhash p99": f(pct(e.hashMs, 99)),
"cold ms": e.cold === undefined ? "failed" : f(e.cold),
"max slot lag": e.lags.length ? Math.max(...e.lags) : "n/a",
errors: e.errors,
})),
);
for (const e of endpoints) if (e.lastError) console.error(`${e.label}: ${e.errors} errors, last: ${e.lastError}`);
if (endpoints.every((e) => e.slotMs.length + e.hashMs.length === 0)) exit("Every request failed. Check the URLs and API keys.");
The companion project adds min/max columns, error counts by type and clean Ctrl+C handling.
Response shapes
getSlot returns a bare number. getLatestBlockhash wraps its value in a context object, following the standard Solana JSON-RPC format:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"context": { "slot": 423867852 },
"value": {
"blockhash": "EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N",
"lastValidBlockHeight": 401927711
}
}
}
Store lastValidBlockHeight with the blockhash and compare it with block height, not slot, when checking expiry. Errors arrive as { "error": { "code", "message" } }, often with HTTP 200, so the script checks both body and status.
How to reduce Solana RPC latency, step by step
Work through these in order. The early steps are free and usually remove more delay than the paid ones.
- Move your code next to the node. Benchmark from the region where your bot runs. If the numbers are dominated by round-trip time, move the bot or pick an endpoint closer to it. Dedicated nodes can be deployed in 11 regions: New York, Los Angeles, Utah, St. Louis, Chicago, Frankfurt, Strasbourg, Amsterdam, Šiauliai, Tokyo and Singapore.
- Keep connections warm. Create one HTTP client or
Connectionper process and reuse it. Keep latency-sensitive paths off serverless cold starts. - Make fewer, cheaper calls. Replace loops of
getAccountInfowith onegetMultipleAccountscall. UsegetTokenAccountsByOwnersto read one mint's balance across up to 250 wallets in a single request. Replace fullgetProgramAccountsscans withgetProgramAccountsV2, which pages through an index (up to 10,000 accounts per page) and supportschangedSinceto fetch only accounts modified after a slot. On Solana Tracker the V2 methods cost 1 credit instead of 10. - Batch with care. A JSON-RPC batch saves round trips, but each call inside it still counts against credits and rate limits. Do not assume a batch is billed as one call.
- Choose the weakest commitment you can accept. Use
confirmedfor most reads,processedonly where you handle rollbacks, andfinalizedonly where you need it. - Subscribe instead of polling. Use WebSocket subscriptions (
accountSubscribe,programSubscribe,logsSubscribe,slotSubscribe) for live changes, and HTTP to load or recover state after a reconnect. Subscriptions do not replay missed updates. - Use Shredstream for the earliest view of transactions.
shredSubscribedelivers transactions reconstructed from shreds before execution metadata is available. Treat these events as provisional and confirm the signature through RPC before acting on them as settled. Shredstream is included on every paid Solana RPC plan. - Use Yellowstone gRPC for filtered, high-volume streams. When you need every transaction for a program or every write to a set of accounts, a gRPC stream with server-side filters beats WebSocket fan-out. Yellowstone gRPC is €200/mo standalone and included on the Business and Professional RPC plans; the Yellowstone gRPC tutorial walks through a working client.
- Remove neighbors with a dedicated node. If your benchmark shows tail latency or 429s that track your own load, shared capacity is the constraint. A dedicated node serves only your traffic with no rate limits.
Comparison: options to cut RPC latency
| Option | What it removes | Trade-off | On Solana Tracker |
|---|---|---|---|
| Same-region deployment | Network round-trip time | May mean moving your bot | Dedicated nodes in 11 regions |
| Connection reuse (keep-alive) | DNS, TCP and TLS setup per call | None for long-running processes | Client-side change |
getMultipleAccounts, V2 methods | Round trips and scan time | V2 response formats differ from stock methods | V2 methods cost 1 credit |
| Lower commitment | Wait for stronger confirmation | processed data can roll back | All plans |
| WebSocket subscriptions | Polling delay and wasted calls | Reconnect and recovery logic | 2 connections on Free, more on paid plans |
Shredstream (shredSubscribe) | Waiting for executed, confirmed transactions | Provisional, no execution metadata | Every paid shared plan |
| Yellowstone gRPC | Polling and WebSocket fan-out | gRPC client, ping handling | €200/mo standalone, included on Business and Professional |
| Dedicated node | Shared rate limits and neighbor load | Fixed monthly cost | From €900/mo |
Production pitfalls
- Benchmarking
getHealth. It says whether the node considers itself healthy, not how fast your real methods run. - Ignoring 429s. On shared RPC, requests above your plan's rate limit return HTTP 429. Rate limits are per plan: 5 requests per second on Free, 60 on Developer, 225 on Business and 500 on Professional, with lower separate limits for
sendTransactionandgetProgramAccounts. Back off with jitter; raising concurrency does not raise the limit. - Treating a signature as a landed transaction.
sendTransactionreturns a signature when the node accepts the transaction. Track its status before you decide to resend, and remember that re-signing creates a different transaction. - Logging the endpoint URL. Shared RPC URLs carry the API key in the query string. Redact it in logs, metrics labels and error messages.
If you are choosing between providers rather than tuning one, the Solana RPC providers guide covers the full evaluation. If your transactions are hitting the size limit, see address lookup tables over RPC.
FAQ
What is a good Solana RPC latency?
There is no universal number, because round-trip time depends mostly on the distance between your code and the node. Compare endpoints from your own production region, and watch p99 and slot lag as well as p50.
Will a dedicated node always reduce latency?
No. It removes shared rate limits and neighbor load, not network distance or the cost of heavy methods. Upgrade when tail latency or 429s track your own traffic.
Is Shredstream faster than Yellowstone gRPC?
They solve different problems. Shredstream delivers provisional transactions reconstructed from shreds, before execution metadata exists. Yellowstone gRPC streams filtered accounts, transactions, slots and blocks with full metadata. Many bots use Shredstream for early signals and confirm through RPC or gRPC.
Should I use processed commitment to reduce latency?
Only where you can handle rollbacks. processed data can be reverted. Use confirmed for most reads and keep commitment identical when you compare endpoints.
References
- Solana RPC API reference
- Solana RPC quickstart
- Credits and rate limits
- WebSocket subscriptions
- shredSubscribe
- getProgramAccountsV2
- getLatestBlockhash
- Pricing and limits
- Yellowstone gRPC best practices
Companion project
The full benchmark tool lives at solanatracker/examples/06-reduce-solana-rpc-latency. Set SOLANA_RPC_URL (or RPC_URLS) in .env, then run:
cp .env.example .env && npm install && npm start