Solana RPC: A Developer's Guide to Endpoints, Methods, and Production Setup
Solana's RPC interface is unlike anything in the EVM world. There's no eth_call, no gas, no EVM bytecode. Instead, you interact with programs through serialized instructions, parse accounts by their owner, and deal with a chain that produces blocks every 400 milliseconds. If you're coming from Ethereum, everything you know about RPC needs to be relearned — the concepts are similar, but the implementation is completely different.
This is the practical reference for connecting to Solana via JSON-RPC. The methods, the commitment levels, the account model, and the production concerns that actually matter when you're building against Solana mainnet.
The essentials
Solana mainnet is chain ID 101 (in the cluster namespace), with:
- Block time: ~400 milliseconds
- Fee token: SOL
- Consensus: Proof-of-history + Tower BFT
- Finality: ~13 seconds (confirmed), ~33 seconds (finalized)
- State model: Accounts, not storage slots — everything is an account
Solana doesn't use EVM-compatible JSON-RPC. The interface is its own specification, and the methods are named differently (getAccountInfo instead of eth_getBalance, sendTransaction instead of eth_sendRawTransaction). Any Solana SDK — @solana/web3.js, anchor, solana-py — wraps these methods.
Connecting: the setup
curl (the universal fallback)
# Get the latest blockhash
curl -s -X POST https://rpc.swiftnodes.io/rpc/sol?key=YOUR_API_KEY \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getLatestBlockhash"}'
# Get an account's balance (in lamports)
curl -s -X POST https://rpc.swiftnodes.io/rpc/sol?key=YOUR_API_KEY \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getBalance","params":["YOUR_PUBLIC_KEY"]}'
# -> {"jsonrpc":"2.0","result":{"context":{"slot":312000000},"value":1000000000}}
# Get the current slot
curl -s -X POST https://rpc.swiftnodes.io/rpc/sol?key=YOUR_API_KEY \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"getSlot"}'
@solana/web3.js (the standard SDK)
import { Connection, PublicKey } from '@solana/web3.js';
const connection = new Connection(
'https://rpc.swiftnodes.io/rpc/sol?key=YOUR_API_KEY',
'confirmed'
);
// Get balance
const balance = await connection.getBalance(new PublicKey('YOUR_PUBLIC_KEY'));
console.log(`Balance: ${balance / 1e9} SOL`);
// Get latest blockhash
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
// Get account info
const accountInfo = await connection.getAccountInfo(new PublicKey('PROGRAM_ID'));
console.log(`Owner: ${accountInfo.owner.toBase58()}`);
console.log(`Data length: ${accountInfo.data.length} bytes`);
Anchor (for program interaction)
import { Program, AnchorProvider, setProvider } from '@coral-xyz/anchor';
import { Connection, Keypair } from '@solana/web3.js';
const connection = new Connection(
'https://rpc.swiftnodes.io/rpc/sol?key=YOUR_API_KEY',
'confirmed'
);
const provider = new AnchorProvider(connection, wallet, { commitment: 'confirmed' });
setProvider(provider);
const program = Program.atlas(PROGRAM_ID, idl);
const account = await program.account.myAccount.fetch(accountAddress);
The methods you'll actually use
Solana has dozens of RPC methods. Here are the ones that cover 95% of real-world usage:
| Method | What it does | When you need it |
|---|---|---|
getLatestBlockhash |
Recent blockhash + valid-until height | Every transaction |
getBalance |
SOL balance for a public key | Wallet UIs, balance checks |
getAccountInfo |
Full account data + owner + lamports | Reading any on-chain state |
sendTransaction |
Submit a signed transaction | Sending transactions |
getSignatureStatuses |
Transaction status by signature | Confirming transactions |
getSignaturesForAddress |
Transaction history for an address | Activity feeds, history |
getProgramAccounts |
All accounts owned by a program | Indexing, analytics |
getBlock |
Full block with transactions | Block explorers, analytics |
getSlot |
Current slot number | Health checks, sync monitoring |
getHealth |
Node health status | Monitoring, load balancers |
The two methods that cause the most trouble
getProgramAccounts is the most expensive call on Solana. It returns every account owned by a program — for large programs like token mints or DEXes, that can be hundreds of thousands of accounts. The response can be hundreds of megabytes. Most RPC providers rate-limit or outright block this method.
The fix is to use dataSlice to request only the bytes you need, and filters to narrow the results:
const accounts = await connection.getProgramAccounts(
new PublicKey('TOKEN_PROGRAM_ID'),
{
dataSlice: { offset: 0, length: 0 }, // Don't fetch account data
filters: [
{ dataSize: 165 }, // Only token accounts (165 bytes)
{ memcmp: { offset: 0, bytes: mintAddress.toBase58() } }, // For a specific mint
],
}
);
Without filters, getProgramAccounts on a popular program will time out, get rejected, or eat through your rate limit in a single call.
sendTransaction has a subtlety that catches everyone: Solana transactions expire. Every transaction includes a recent blockhash, and if that blockhash is older than ~150 blocks (~1 minute), the transaction is rejected. If your transaction isn't landing, it's usually because the blockhash expired before the network included it.
The fix: use a durable nonce for transactions that need to survive longer than a minute, or implement a retry loop with fresh blockhashes:
async function sendWithRetry(connection, transaction, retries = 5) {
for (let i = 0; i < retries; i++) {
const { blockhash } = await connection.getLatestBlockhash('confirmed');
transaction.recentBlockhash = blockhash;
try {
const sig = await connection.sendRawTransaction(transaction.serialize());
await connection.confirmTransaction(sig, 'confirmed');
return sig;
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(r => setTimeout(r, 1000));
}
}
}
Commitment levels
Solana's commitment model is different from Ethereum's block tags. Instead of latest/finalized, Solana uses a voting-based system:
| Commitment | Meaning | When to use |
|---|---|---|
processed |
The node has received the block but it hasn't been confirmed by the cluster | Real-time UIs, non-critical reads |
confirmed |
The block has been voted on by a supermajority of the validator set | Default for most operations — good balance of speed and safety |
finalized |
The block has been finalized by the cluster and cannot be reverted | When reorgs are unacceptable (settlement, accounting) |
The practical difference:
processed→ ~400ms latency, but the block could be droppedconfirmed→ ~5-10 seconds latency, very unlikely to be revertedfinalized→ ~33 seconds latency, guaranteed permanent
For most applications, confirmed is the right default. Use finalized for anything involving value transfers or state that other systems depend on. Never use processed for anything that handles money.
// Different commitment for different operations
const balance = await connection.getBalance(pubkey, 'confirmed');
const tx = await connection.sendTransaction(signedTx);
await connection.confirmTransaction(tx, 'finalized'); // Wait for finality
WebSocket subscriptions
Solana's WebSocket API is essential for real-time applications. Instead of polling getSlot or getSignatureStatuses, you subscribe to updates and the node pushes data to you.
import { Connection, PublicKey } from '@solana/web3.js';
const connection = new Connection(
'wss://rpc.swiftnodes.io/ws/sol?key=YOUR_API_KEY',
'confirmed'
);
// Subscribe to account changes
const subscriptionId = connection.onAccountChange(
new PublicKey('ACCOUNT_ADDRESS'),
(accountInfo, context) => {
console.log(`Account changed at slot ${context.slot}`);
console.log(`New data length: ${accountInfo.data.length}`);
}
);
// Subscribe to new blocks
const slotSub = connection.onSlotChange((slotInfo) => {
console.log(`New slot: ${slotInfo.slot}`);
});
// Subscribe to transaction logs for an address
const logSub = connection.onLogs(
new PublicKey('PROGRAM_ID'),
(logs, context) => {
console.log(`Transaction at slot ${context.slot}:`);
logs.logs.forEach(log => console.log(` ${log}`));
}
);
// Unsubscribe when done
connection.removeAccountChangeListener(subscriptionId);
connection.removeSlotChangeListener(slotSub);
connection.removeOnLogsListener(logSub);
The common mistake is polling getSignatureStatuses in a tight loop to check if a transaction landed. On Solana's 400ms block time, this generates enormous request volume. Use onSignature over WebSocket instead:
const sig = await connection.sendRawTransaction(tx.serialize());
await new Promise((resolve, reject) => {
connection.onSignature(sig, (result) => {
if (result.err) reject(result.err);
else resolve(result);
}, 'confirmed');
});
Parsing accounts
Everything on Solana is an account — tokens, NFTs, program state, even SOL balances are stored in system accounts. But accounts are just raw bytes. To make sense of them, you need to know the account's owner program and how that program serializes data.
Token accounts
Token accounts are owned by the Token Program (TokenkegQfeZyiNwAJbNbGKPFXCWuBv9DhWgFKTAhfBpJ6) and are 165 bytes. The layout is:
import { struct, u64, publicKey } from '@solana/buffer-layout-utils';
const TokenAccountLayout = struct([
publicKey('mint'),
publicKey('owner'),
u64('amount'),
// ... delegate, state, isNative, etc.
]);
const parsed = TokenAccountLayout.decode(accountInfo.data);
console.log(`Token balance: ${parsed.amount}`);
Or use the built-in parser:
const tokenBalance = await connection.getTokenAccountBalance(tokenAccountPubkey);
console.log(`Balance: ${tokenBalance.value.uiAmount} ${tokenBalance.value.decimals} decimals`);
Program-derived addresses (PDAs)
Many Solana programs store state in PDAs — accounts whose addresses are derived from a set of seeds and a program ID. To read PDA state, you need to know the seeds:
// Find the PDA for a user's game state
const [gameStatePDA, bump] = PublicKey.findProgramAddressSync(
[Buffer.from('game_state'), userPubkey.toBuffer()],
programId
);
const gameState = await program.account.gameState.fetch(gameStatePDA);
Transaction simulation
Before sending a transaction, simulate it to catch errors without spending fees:
const simulation = await connection.simulateTransaction(signedTx);
if (simulation.value.err) {
console.error('Transaction would fail:', simulation.value.err);
console.error('Logs:', simulation.value.logs);
} else {
console.log('Simulation passed, compute units:', simulation.value.unitsConsumed);
// Now send the real transaction
const sig = await connection.sendRawTransaction(signedTx.serialize());
}
Simulation returns the same logs that the transaction would produce if executed, including compute unit consumption. Use this to set the compute unit limit precisely and avoid overpaying for fees.
Priority fees
Solana's base fee is 5,000 lamports per signature (~$0.00001). During congestion, you can add a priority fee to get your transaction included faster:
import { ComputeBudgetProgram } from '@solana/web3.js';
// Get the recent priority fees for a specific program
const fees = await connection.getRecentPrioritizationFees({
lockedWritableAccounts: [new PublicKey('JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4')]
});
// Calculate a reasonable priority fee (median of recent fees)
const sortedFees = fees.map(f => f.prioritizationFee).sort((a, b) => a - b);
const medianFee = sortedFees[Math.floor(sortedFees.length / 2)];
// Add the priority fee instruction to your transaction
const priorityFeeIx = ComputeBudgetProgram.setComputeUnitPrice({
microLamports: medianFee,
});
transaction.add(priorityFeeIx, ...otherInstructions);
During normal conditions, no priority fee is needed. During high congestion (popular NFT mints, DeFi events), priority fees of 1,000-100,000 micro-lamports per compute unit can make the difference between your transaction landing or expiring.
Rate limits and production concerns
Solana RPC providers typically limit:
- Requests per second — usually 10-100 RPS on free tiers
- Heavy methods —
getProgramAccountsandgetBlockmay have separate, lower limits - Response size — some providers cap response payloads at 10-50MB
- WebSocket connections — concurrent connection limits
The failure mode is HTTP 429 or a timeout on heavy methods. The fixes:
- Use filters and data slices on
getProgramAccounts— this is non-negotiable in production - Batch your requests — Solana supports JSON-RPC batching
- Cache account data — accounts don't change between slots, cache aggressively
- Use WebSocket subscriptions instead of polling — dramatically reduces request volume
// Batch multiple calls into one request
const batch = [
{ jsonrpc: '2.0', id: 1, method: 'getBalance', params: [pubkey1] },
{ jsonrpc: '2.0', id: 2, method: 'getBalance', params: [pubkey2] },
{ jsonrpc: '2.0', id: 3, method: 'getBalance', params: [pubkey3] },
];
const responses = await fetch(RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch),
});
Choosing a Solana RPC endpoint
The decision comes down to three things:
1. Do you need getProgramAccounts? If your application indexes program state, you need a provider that supports this method with reasonable rate limits. Many free tiers block it entirely.
2. What's your transaction volume? Low-traffic dApps can use free tiers. Anything with real users will need a paid plan. Calculate your expected requests: a moderately active dApp with 1,000 daily users making ~20 reads each = 20,000 requests/day.
3. Do you need WebSocket subscriptions? If you're building anything real-time — a trading interface, a game, a monitoring dashboard — WebSocket support is essential. Not all providers offer it, and the ones that do may limit concurrent connections.
Other factors — latency, geographic distribution, stake-weighted quality of service — matter but are secondary. An endpoint with 50ms latency that blocks getProgramAccounts when you need it is useless.
The short version
Solana mainnet runs at ~400ms block time, SOL gas, Proof-of-history consensus. The RPC interface is its own spec — getAccountInfo, sendTransaction, getProgramAccounts, and the rest. Use confirmed for most reads, finalized when reorgs are unacceptable. Always filter getProgramAccounts with dataSlice and memcmp. Use WebSocket subscriptions instead of polling. Set compute unit limits from simulation results. Add priority fees during congestion.
For Solana RPC access across load-balanced validators with WebSocket support and no getProgramAccounts restrictions, grab a free API key and point your app at:
https://rpc.swiftnodes.io/rpc/sol?key=YOUR_API_KEY
John Sullivan covers RPC infrastructure, node operations, and multi-chain development at SwiftNodes — what it actually takes to keep endpoints fast, fresh, and reliable across EVM and non-EVM networks.
Related posts
- Gnosis Chain RPC: A Developer's Guide to the Community-Owned EVM Chain
How to connect to Gnosis Chain via JSON-RPC — the dual-token model (xDAI + GNO), DAI-bridged stablecoin gas, consensus via GPOS, and what makes it different from other EVM chains. Includes production setup and fee optimization.
- Tron RPC: A Developer's Guide to TRC-20 Transfers, Smart Contracts, and Node Setup
How to connect to Tron via JSON-RPC and HTTP — TRC-20 token transfers, smart contract interaction, energy and bandwidth, and how to pick a Tron endpoint for production use.
- Ethereum RPC: The Complete Developer Reference
Everything you need to connect to Ethereum via RPC — the methods you'll actually use, how to set up viem and ethers, WebSocket vs HTTP, archive access, and how to pick an endpoint that won't fall over under load.