Blog

Developer Guide

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:

APIEndpointDefault Public Limit
Horizonhorizon.stellar.org~5 requests/second per IP
Stellar RPCmainnet.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:

DataFreshness NeededSuggested Poll Interval
Account balancesModerate30 seconds
Network ledgerHigh5 seconds
Fee statsLow60 seconds
Transaction historyLow30 seconds
Asset listVery low5 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:

SignalWhy It Matters
You are hitting 429s regularlyYour users see errors
You need >5 req/s from one serverBackend aggregation
You need guaranteed uptimePublic endpoints have no SLA
You need faster response timesPublic endpoints add latency
You need historical data accessDeep pagination is throttled
You are building for productionRate limits will bite at scale

LumenQuery API Tiers

TierRate LimitPriceBest For
Free10 req/min$0Prototyping
Developer200 req/min$25/moSide projects, MVPs
Team1,000 req/min$99/moProduction apps
EnterpriseCustomCustomHigh-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)
  • Frontend calls your API, never Horizon directly
  • Your API checks Redis cache first
  • Cache miss goes to LumenQuery (which has its own cache)
  • Background jobs pre-warm caches for common queries
  • Historical data is stored in your own database
  • 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

    StrategyImpact
    Use cursors instead of re-fetching80-90% fewer requests
    Cache with appropriate TTLs50-80% fewer requests
    Use SSE for real-time dataEliminates polling entirely
    Batch related queries60-70% fewer requests
    Use managed API with higher limitsRemoves 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.*