Stellar API Rate Limits Explained: How to Design Apps That Don't Break in Production
Rate limits are one of the most common reasons blockchain applications break in production. Everything works fine in development with a few test requests, but the moment real users show up, your app starts throwing 429 errors and the dashboard goes blank. This guide explains how Stellar API rate limits work and how to build applications that handle them gracefully.
How Rate Limits Work on Stellar
Stellar has two main API surfaces, and each has its own rate limiting behavior:
| API | Endpoint | Default Public Limit |
|---|---|---|
| Horizon | horizon.stellar.org | ~5 requests/second per IP |
| Stellar RPC | mainnet.sorobanrpc.com | ~1 request/second per IP |
These limits are per IP address. If you are running a backend server, all requests from your server count as a single IP. If you have 100 users hitting your frontend, each gets their own limit — unless all requests are proxied through your server.
What Happens When You Hit the Limit
When you exceed the rate limit, the API returns HTTP 429 (Too Many Requests):
{
"type": "https://stellar.org/horizon-errors/rate_limit_exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "The rate limit for the requesting IP address is over the allowed limit."
}The response includes a Retry-After header telling you how many seconds to wait. Ignoring this and retrying immediately will extend your lockout.
Common Patterns That Burn Through Limits
1. Polling Without Cursors
The most common mistake is repeatedly fetching the same endpoint to check for updates:
// Bad: Fetches everything every 2 seconds
setInterval(async () => {
const res = await fetch(
'https://horizon.stellar.org/accounts/GABC.../payments?limit=10&order=desc'
);
// process...
}, 2000);Instead, use cursors to only fetch new records:
// Good: Only fetches new payments since last check
let cursor = 'now';
setInterval(async () => {
const res = await fetch(
`https://horizon.stellar.org/accounts/GABC.../payments?cursor=${cursor}&order=asc&limit=50`
);
const data = await res.json();
for (const record of data._embedded.records) {
cursor = record.paging_token;
// process new record...
}
}, 5000);2. Fetching Redundant Data
If your dashboard shows account balances, network stats, and recent transactions, do not fetch them all every 5 seconds. Different data has different freshness requirements:
| Data | Freshness Needed | Suggested Poll Interval |
|---|---|---|
| Account balances | Moderate | 30 seconds |
| Network ledger | High | 5 seconds |
| Fee stats | Low | 60 seconds |
| Transaction history | Low | 30 seconds |
| Asset list | Very low | 5 minutes |
3. N+1 Query Patterns
Fetching a list of items and then making an additional request for each one:
// Bad: 1 request + 10 detail requests = 11 requests
const txs = await fetch('.../transactions?limit=10');
for (const tx of txs) {
const ops = await fetch(`.../transactions/${tx.hash}/operations`);
}Batch your requests or use endpoints that embed related data.
Building a Rate-Limit-Aware Client
class StellarClient {
constructor(baseUrl, maxRetries = 3) {
this.baseUrl = baseUrl;
this.maxRetries = maxRetries;
this.queue = [];
this.processing = false;
}
async fetch(path) {
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
const res = await fetch(`${this.baseUrl}${path}`);
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') || '5');
console.warn(`Rate limited. Retrying in ${retryAfter}s`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
continue;
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
}
throw new Error('Max retries exceeded');
}
}Using a Cache Layer
Caching is the single most effective way to reduce API calls. Most Stellar data changes slowly:
const cache = new Map();
async function cachedFetch(url, ttlMs) {
const cached = cache.get(url);
if (cached && Date.now() - cached.time < ttlMs) {
return cached.data;
}
const data = await fetch(url).then(r => r.json());
cache.set(url, { data, time: Date.now() });
return data;
}
// Usage
const feeStats = await cachedFetch(
'https://horizon.stellar.org/fee_stats',
60000 // cache for 60 seconds
);For production applications, use Redis instead of an in-memory Map. LumenQuery's API includes a built-in Redis cache layer with configurable TTLs per endpoint.
Use SSE Instead of Polling
Horizon supports Server-Sent Events (SSE) for real-time streaming. One persistent connection replaces hundreds of polling requests:
// One connection, zero polling, instant updates
const es = new EventSource(
'https://horizon.stellar.org/ledgers?cursor=now'
);
es.onmessage = (event) => {
const ledger = JSON.parse(event.data);
console.log('New ledger:', ledger.sequence);
};SSE is available for ledgers, transactions, operations, payments, effects, and order book changes.
When to Upgrade from Public Endpoints
You need dedicated infrastructure when:
| Signal | Why It Matters |
|---|---|
| You are hitting 429s regularly | Your users see errors |
| You need >5 req/s from one server | Backend aggregation |
| You need guaranteed uptime | Public endpoints have no SLA |
| You need faster response times | Public endpoints add latency |
| You need historical data access | Deep pagination is throttled |
| You are building for production | Rate limits will bite at scale |
LumenQuery API Tiers
| Tier | Rate Limit | Price | Best For |
|---|---|---|---|
| Free | 10 req/min | $0 | Prototyping |
| Developer | 200 req/min | $25/mo | Side projects, MVPs |
| Team | 1,000 req/min | $99/mo | Production apps |
| Enterprise | Custom | Custom | High-throughput |
All tiers include built-in caching, usage analytics, and no cold starts.
Architecture for High-Throughput Apps
For applications that need to process thousands of transactions per minute:
Client requests → Your API → LumenQuery (cached) → Horizon/RPC
↓
Redis cache (30s-5min TTL)
↓
PostgreSQL (historical data)This pattern can handle tens of thousands of concurrent users with a single LumenQuery Team plan.
Testing Rate Limit Behavior
Before going to production, simulate rate limit scenarios:
async function testRateLimits() {
const results = [];
for (let i = 0; i < 20; i++) {
const start = Date.now();
const res = await fetch('https://horizon.stellar.org/ledgers?limit=1');
results.push({
attempt: i + 1,
status: res.status,
latencyMs: Date.now() - start,
});
}
const limited = results.filter(r => r.status === 429);
console.log(`Hit rate limit after ${results.length - limited.length} requests`);
console.log(`${limited.length} requests were throttled`);
}Summary
| Strategy | Impact |
|---|---|
| Use cursors instead of re-fetching | 80-90% fewer requests |
| Cache with appropriate TTLs | 50-80% fewer requests |
| Use SSE for real-time data | Eliminates polling entirely |
| Batch related queries | 60-70% fewer requests |
| Use managed API with higher limits | Removes the ceiling |
The goal is not to fight rate limits — it is to design your application so that they never matter.
*Stop fighting rate limits. LumenQuery provides managed Stellar API access with generous rate limits, built-in caching, and usage analytics. Start with the free tier.*