How to Compare Solana RPC Rate Limits and Usage Tiers Without Guessing
A practical method for measuring Solana RPC rate limits and usage tiers yourself, using a Node.js script that probes 429 responses, retry headers, and per-method behavior so you can pick a provider tier that fits your workload.
Provider comparison pages go stale fast, and most of them repeat marketing copy instead of telling you what happens when your client exceeds a limit. The useful question is narrower: for the specific methods your app calls, at the request rate your app generates, what does the endpoint do when you cross the line? This guide gives you a small Node.js 18+ harness that answers that question against any Solana RPC endpoint you have credentials for, then explains how to read the results and where RPC access ends and indexing begins.
Why you should measure instead of trusting a tier table
Rate limits on hosted Solana RPC are usually enforced per API key, per IP, or per plan, and the enforcement point is often a gateway in front of the node rather than the node itself. That means the observable behavior — HTTP 429, a Retry-After header, a JSON-RPC error object, or a silent connection reset — is the contract you actually code against. Two providers advertising the same requests-per-second number can behave very differently under bursty traffic, and a tier table cannot tell you which methods are weighted more heavily.
There is a second reason to measure: your workload is not uniform. getHealth and getSlot are cheap and cacheable; getProgramAccounts with a large filter and getSignaturesForAddress over a long history are expensive and may be throttled or disabled on lower tiers regardless of your headline request budget. Measuring per method tells you which calls need a dedicated plan, a cache, or a different data source entirely.
None of this requires you to trust a benchmark from a blog post. You can generate the numbers yourself in a few minutes against a test key, and re-run the script whenever you change plans or providers.
- Rate limits are enforced at a gateway, so the observable 429/retry behavior is what your client must handle
- Method cost is not uniform; expensive calls may be restricted independently of your request budget
- A short self-run probe beats any static comparison table for your own traffic shape
Write a probe that records 429s and retry headers
The script below sends a controlled burst of JSON-RPC requests to a Solana endpoint and records, for each response, the HTTP status, the Retry-After header if present, and the JSON-RPC error code. It deliberately uses only cheap methods so you are measuring the rate limiter rather than the cost of an expensive query. Run it against a test key, not a production key, and keep the burst small.
Set the endpoint through an environment variable so you never hardcode a URL. If you are evaluating OnFinality's Solana offering, the endpoint and plan details live on the Solana network page; copy the URL from your own dashboard rather than from any example.
The script exits non-zero if it sees a 429, which makes it usable in CI as a smoke test that your configured concurrency stays under your plan's limit.
- Use only cheap methods (
getHealth,getSlot,getLatestBlockhash) so you isolate the limiter - Read
Retry-Afterand JSON-RPCerror.code; both are common throttle signals - Never hardcode endpoints; load them from the environment
// probe.mjs — Node.js 18+ (global fetch is available)
// Usage: SOLANA_RPC_URL="https://your-endpoint" CONCURRENCY=10 REQUESTS=100 node probe.mjs
const url = process.env.SOLANA_RPC_URL;
const concurrency = Number(process.env.CONCURRENCY ?? 10);
const total = Number(process.env.REQUESTS ?? 100);
if (!url) {
console.error('Missing SOLANA_RPC_URL');
process.exit(2);
}
const methods = ['getHealth', 'getSlot', 'getLatestBlockhash'];
const results = { ok: 0, http429: 0, rpcError: 0, networkError: 0, retryAfter: [] };
async function callOnce(i) {
const method = methods[i % methods.length];
const body = JSON.stringify({ jsonrpc: '2.0', id: i, method, params: [] });
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
signal: AbortSignal.timeout(10_000),
});
const retryAfter = res.headers.get('retry-after');
if (retryAfter) results.retryAfter.push(retryAfter);
if (res.status === 429) {
results.http429++;
return;
}
if (!res.ok) {
results.networkError++;
console.warn(`HTTP ${res.status} for ${method}`);
return;
}
const json = await res.json();
if (json.error) {
results.rpcError++;
console.warn(`RPC error ${json.error.code} on ${method}: ${json.error.message}`);
return;
}
results.ok++;
} catch (err) {
results.networkError++;
console.warn(`Network error on ${method}: ${err.name}`);
}
}
async function main() {
let next = 0;
async function worker() {
while (next < total) {
const i = next++;
await callOnce(i);
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
console.log(JSON.stringify(results, null, 2));
if (results.http429 > 0) {
console.error('Throttled: reduce CONCURRENCY or move to a higher usage tier.');
process.exit(1);
}
}
main();Expected result on a healthy tier: a JSON summary with ok equal to REQUESTS and http429 at zero. If you see 429s, the script exits with code 1 and prints the count — that is your signal to lower concurrency or change tier.
If you see rpcError instead of 429, the gateway let the request through but the node rejected it. That is a different problem: check the method and parameters, not your rate limit.
Turning probe output into a tier decision
Run the probe at increasing concurrency — 5, 10, 25, 50 — and note the first level where 429s appear. That threshold, not the advertised number, is the ceiling you should design around. Leave headroom, because your production traffic is burstier than a uniform loop and because other services sharing the key will consume the same budget.
Then repeat the probe with the expensive methods your app actually uses, one at a time, at low concurrency. If getProgramAccounts returns a JSON-RPC error while cheap methods succeed, that method is gated by your plan and no amount of rate-limit tuning will fix it. In that case you either need a higher tier or a different access pattern.
Record the date and the plan name next to each result. Provider limits change, and a measurement from six months ago is a historical note, not a current fact. If you want a starting point for what tiers typically include, the RPC Assistant walks through endpoint options, but treat any published number as a hypothesis to verify with your own probe.
- Find the concurrency where 429s first appear, then design below it with headroom
- Test expensive methods separately; a gated method is a plan problem, not a rate problem
- Timestamp every measurement and re-run after any plan or provider change
RPC access is not indexing — know which one you need
A rate-limited RPC endpoint answers point queries: current slot, account state, a transaction by signature, a recent blockhash. It is not a query engine. If your product needs "all accounts owned by this program that match a filter, paginated and sorted," or historical aggregates over millions of transactions, you are describing an indexing workload, and pushing it through RPC calls will hit both rate limits and practical response-size limits.
The distinction matters when you choose a tier. Buying a higher RPC tier to make getProgramAccounts scans faster is usually the wrong purchase; the right move is to index the program's data once and query the index. Conversely, paying for an indexer when you only need getSlot and sendTransaction is wasted spend.
A useful rule: if a single user action in your app triggers more than a handful of RPC calls, or if you need to query by a field the RPC API does not filter on, that is an indexing signal. The OnFinality Learn section covers indexing concepts if you are new to the split.
- RPC = point lookups and transaction submission; indexing = filtered, paginated, historical queries
- Do not buy a bigger RPC tier to solve a query-shape problem
- If one user action fans out into many RPC calls, consider indexing that data instead
Handle throttling in the client, not just in the probe
Your probe tells you the limit; your production client has to survive crossing it. The minimum viable handling is: retry on 429 and on JSON-RPC errors that indicate throttling, honor Retry-After when present, use exponential backoff with jitter when it is absent, and cap total retries so a persistent throttle surfaces as an error rather than a hang.
The wrapper below adds that behavior around a single call. It is intentionally small so you can read it; adapt the retry predicate to the error codes your provider actually returns, which your probe output will show you.
Do not retry non-idempotent submissions blindly. sendTransaction retries can double-submit; check whether the transaction already landed before resending, or use the same signature so the network deduplicates it.
- Retry 429s with
Retry-Afterwhen present, exponential backoff with jitter otherwise - Cap retries so throttling fails loudly instead of hanging
- Treat
sendTransactiondifferently from read calls to avoid double submission
// rpc-client.mjs — minimal throttle-aware wrapper
const MAX_RETRIES = 5;
function backoff(attempt) {
const base = Math.min(2 ** attempt * 200, 5000);
return base + Math.random() * 250; // jitter
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function rpcCall(url, method, params = []) {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
signal: AbortSignal.timeout(15_000),
});
if (res.status === 429) {
const ra = Number(res.headers.get('retry-after'));
await sleep(Number.isFinite(ra) ? ra * 1000 : backoff(attempt));
continue;
}
if (!res.ok) {
throw new Error(`HTTP ${res.status} calling ${method}`);
}
const json = await res.json();
if (json.error) {
// 429-equivalent RPC codes vary by provider; log and inspect before retrying
throw new Error(`RPC ${json.error.code}: ${json.error.message}`);
}
return json.result;
}
throw new Error(`Exhausted retries calling ${method} — likely throttled`);
}Expected result: calls succeed under normal load, and under throttling they either recover after a short wait or throw a clear "Exhausted retries" error you can alert on.
If you see the exhausted-retries error repeatedly at your normal traffic level, that is your cue to reduce concurrency or move up a usage tier — not to raise MAX_RETRIES.