Everything I’ve ever built on Solana went through the same phase. Works on my machine. Works in testing. Then it does something real, and suddenly it’s drowning in 429 Too Many Requests and half my calls are failing.
The default reaction is “the free RPC is bad, I need to pay for one.” Sometimes true. But most of the time I was getting throttled because of how I was calling it, not which endpoint I was hitting. Fixing the code mattered more than upgrading the plan.
Here’s what’s actually going on when Solana rate-limits you, and the handful of changes that stopped it for me.
The public RPC is a shared resource, not a backend
api.mainnet-beta.solana.com is there so you can experiment. It is not there to power your app. It’s shared by everyone doing exactly what you’re doing, and the rate limits are tight on purpose.
The mistake is treating it like your private backend. The moment your thing does real traffic, you’re competing with the entire ecosystem’s tire-kickers for the same tiny budget. You’ll lose.
So rule zero: don’t ship anything real on the public endpoint. Get your own from a provider. That alone solves a lot. But it won’t solve the part that’s your code, and that part follows you to the paid endpoint.
You’re probably making way more calls than you think
Here’s the thing I got wrong for the longest time. I’d write what looked like one operation and it was secretly a dozen RPC calls.
The classic offender is a loop:
// this is 100 separate RPC calls
for (const addr of addresses) {
const balance = await connection.getBalance(new PublicKey(addr))
balances.push(balance)
}
A hundred addresses, a hundred round trips, fired as fast as the loop runs. Of course you get throttled. You just hammered the endpoint with a burst.
Most of these have a batch version. Learn them, because they turn a burst into a single call:
// one RPC call for up to 100 accounts
const keys = addresses.map((a) => new PublicKey(a))
const accounts = await connection.getMultipleAccountsInfo(keys)
getMultipleAccountsInfo for accounts. getMultipleAccounts under the hood. When you genuinely need many calls, that’s one request instead of a hundred. Before reaching for a bigger plan, go count how many calls your “one operation” actually makes. Mine was always more than I guessed.
Stop polling. Subscribe.
The second big one: I was polling for changes with getAccountInfo on a timer. Every second, ask “did it change.” That’s a call per second per account, forever, mostly returning the same answer.
Solana has WebSocket subscriptions for exactly this. You subscribe once and the RPC pushes you the change when it happens. No polling, no wasted calls, and you find out faster.
// instead of polling getAccountInfo on an interval:
const subId = connection.onAccountChange(
pubkey,
(accountInfo) => {
// fires only when the account actually changes
handleChange(accountInfo)
},
"confirmed",
)
One subscription replaces thousands of polling calls over a day. If you’re watching anything, wallets, pools, program accounts, subscribe to it. Polling should be your last resort, not your default. I wrote a whole thing about monitoring wallets in real time and this is the core of why it scales.
When you do call, back off properly
Even with batching and subscriptions, you’ll hit a 429 sometimes. What you do with it is what separates a resilient app from one that makes things worse.
The wrong move is retrying immediately. A 429 means “slow down,” and retrying instantly is doing the opposite. That’s how a brief throttle turns into a sustained one, because now you’re adding load exactly when you were told to back off.
The right move is exponential backoff with a bit of jitter:
async function withRetry<T>(fn: () => Promise<T>, max = 5): Promise<T> {
for (let attempt = 0; attempt < max; attempt++) {
try {
return await fn()
} catch (err: any) {
if (err?.message?.includes("429") && attempt < max - 1) {
const base = 2 ** attempt * 200 // 200, 400, 800...
const jitter = base * 0.3 * Math.random()
await new Promise((r) => setTimeout(r, base + jitter))
continue
}
throw err
}
}
throw new Error("retries exhausted")
}
The jitter matters more than it looks. Without it, if a bunch of your calls fail at once, they all retry at the exact same moment and hammer the endpoint in sync. Jitter spreads them out so they don’t stampede. This one change fixed a “throttle that never recovered” bug for me.
Cap your own concurrency
The last piece is the one that finally made it stable. Even with backoff, if I fired off 500 calls at once, 480 of them failed and retried, and the whole thing thrashed.
The fix is to never fire that many at once in the first place. Cap how many requests are in flight. A tiny concurrency limiter does it:
import pLimit from "p-limit"
const limit = pLimit(10) // at most 10 in-flight requests
const results = await Promise.all(
items.map((item) => limit(() => fetchOne(item))),
)
Ten at a time instead of five hundred. It’s slightly slower in the best case and dramatically more reliable in the real case, because you’re staying under the endpoint’s budget instead of blowing past it and spending all your time retrying. Slower-but-lands beats fast-but-fails every time.
What order to fix these
If you’re getting throttled right now, here’s the order that gives you the most relief per unit of effort. Get off the public endpoint. Batch your loops into multi-account calls. Replace polling with subscriptions. Add backoff with jitter. Cap concurrency. Upgrade your plan last, once you’ve confirmed it’s actually volume and not your code.
I jumped straight to “upgrade the plan” the first time and it barely helped, because my code was still firing bursts of hundreds of calls. Fix the pattern first. The plan is the last lever, not the first.
Getting rate-limited on Solana almost never means you need a bigger RPC plan. It means you’re making more calls than you need, in bursts, and retrying wrong when they fail. Batch what you can, subscribe instead of poll, back off with jitter, and cap your concurrency. Do that and a modest endpoint carries a surprising amount of load. Throw money at it first and you’ll just get throttled at a higher price.