Building Real-Time Apps on Stellar: Horizon API vs Stellar RPC
When building on Stellar, developers face a crucial architectural decision: when to use Horizon API versus Stellar RPC. Both provide access to the Stellar network, but they're optimized for different use cases. This guide breaks down the differences with practical examples.
Understanding the Two Services
Horizon API
Horizon is Stellar's REST API server. It maintains a full index of the Stellar network's history and provides:
Stellar RPC
Stellar RPC (formerly Soroban RPC) is a JSON-RPC 2.0 service optimized for:
When to Use Each
| Use Case | Horizon | Stellar RPC | Why |
|---|---|---|---|
| Get account balances | ✓ | ✓ | Both work; Horizon has richer data |
| Transaction history | ✓ | ✗ | Horizon indexes all history |
| Submit payments | ✓ | ✓ | Both can submit; RPC is faster |
| Invoke smart contracts | ✗ | ✓ | RPC has simulation support |
| Query contract state | ✗ | ✓ | RPC reads ledger entries directly |
| Stream transactions | ✓ | ✗ | Horizon has SSE streaming |
| Get fee estimates | ✓ | ✓ | RPC has getFeeStats |
| Build explorers | ✓ | ✗ | Horizon has complete history |
| Build wallets | ✓ | ✓ | Use both for full functionality |
Practical Code Examples
Example 1: Getting Account Balance
Using Horizon (REST)
// horizon-client.ts
const HORIZON_URL = 'https://api.lumenquery.io';
async function getAccountBalances(accountId: string) {
const response = await fetch(
`${HORIZON_URL}/accounts/${accountId}`,
{
headers: { 'X-API-Key': process.env.LUMENQUERY_API_KEY! }
}
);
if (!response.ok) {
if (response.status === 404) {
return { balances: [], exists: false };
}
throw new Error(`Horizon error: ${response.status}`);
}
const account = await response.json();
return {
balances: account.balances.map((b: any) => ({
asset: b.asset_type === 'native' ? 'XLM' : `${b.asset_code}:${b.asset_issuer}`,
balance: b.balance,
})),
exists: true,
sequence: account.sequence,
thresholds: account.thresholds,
};
}Using Stellar RPC (JSON-RPC)
// stellar-rpc-client.ts
import { Address, xdr } from '@stellar/stellar-sdk';
const STELLAR_RPC_URL = 'https://rpc.lumenquery.io';
async function getAccountBalancesRpc(accountId: string) {
// Create the account ledger key
const accountKey = xdr.LedgerKey.account(
new xdr.LedgerKeyAccount({
accountId: Address.fromString(accountId).toScAddress().accountId(),
})
).toXDR('base64');
const response = await fetch(STELLAR_RPC_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LUMENQUERY_API_KEY!,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getLedgerEntries',
params: { keys: [accountKey] },
}),
});
const { result } = await response.json();
if (!result.entries || result.entries.length === 0) {
return { balances: [], exists: false };
}
// Parse the XDR response
const entry = xdr.LedgerEntryData.fromXDR(
result.entries[0].xdr,
'base64'
);
const account = entry.account();
return {
balances: [{
asset: 'XLM',
balance: (BigInt(account.balance().toString()) / 10000000n).toString()
}],
exists: true,
};
}Verdict: Horizon is easier for account data—it returns JSON directly. Use RPC when you need raw ledger state or are already in a Soroban context.
Example 2: Transaction History
Using Horizon (the only option)
interface Transaction {
id: string;
hash: string;
created_at: string;
source_account: string;
fee_charged: string;
operation_count: number;
successful: boolean;
memo?: string;
}
async function getTransactionHistory(
accountId: string,
options: { limit?: number; cursor?: string } = {}
): Promise<{ transactions: Transaction[]; nextCursor: string | null }> {
const limit = options.limit || 20;
const cursor = options.cursor || '';
const url = new URL(`${HORIZON_URL}/accounts/${accountId}/transactions`);
url.searchParams.set('limit', limit.toString());
url.searchParams.set('order', 'desc');
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url.toString(), {
headers: { 'X-API-Key': process.env.LUMENQUERY_API_KEY! }
});
const data = await response.json();
const records = data._embedded?.records || [];
return {
transactions: records.map((tx: any) => ({
id: tx.id,
hash: tx.hash,
created_at: tx.created_at,
source_account: tx.source_account,
fee_charged: tx.fee_charged,
operation_count: tx.operation_count,
successful: tx.successful,
memo: tx.memo,
})),
nextCursor: records.length === limit ? records[records.length - 1].paging_token : null,
};
}
// Usage with pagination
async function getAllTransactions(accountId: string) {
const allTransactions: Transaction[] = [];
let cursor: string | null = null;
do {
const result = await getTransactionHistory(accountId, {
limit: 200,
cursor: cursor || undefined
});
allTransactions.push(...result.transactions);
cursor = result.nextCursor;
} while (cursor);
return allTransactions;
}Verdict: Stellar RPC doesn't index transaction history—use Horizon for any historical queries.
Example 3: Submitting a Payment
Using Horizon
import { Keypair, Networks, Operation, TransactionBuilder } from '@stellar/stellar-sdk';
async function submitPaymentViaHorizon(
sourceKeypair: Keypair,
destination: string,
amount: string
) {
// Load account to get sequence number
const accountResponse = await fetch(
`${HORIZON_URL}/accounts/${sourceKeypair.publicKey()}`,
{ headers: { 'X-API-Key': process.env.LUMENQUERY_API_KEY! } }
);
const sourceAccount = await accountResponse.json();
// Build transaction
const transaction = new TransactionBuilder(
{
accountId: () => sourceAccount.id,
sequenceNumber: () => sourceAccount.sequence,
incrementSequenceNumber: () => {},
},
{
fee: '100',
networkPassphrase: Networks.PUBLIC,
}
)
.addOperation(
Operation.payment({
destination,
asset: Asset.native(),
amount,
})
)
.setTimeout(30)
.build();
transaction.sign(sourceKeypair);
// Submit via Horizon
const submitResponse = await fetch(`${HORIZON_URL}/transactions`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-API-Key': process.env.LUMENQUERY_API_KEY!,
},
body: `tx=${encodeURIComponent(transaction.toXDR())}`,
});
return submitResponse.json();
}Using Stellar RPC
async function submitPaymentViaRpc(signedXdr: string) {
const response = await fetch(STELLAR_RPC_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LUMENQUERY_API_KEY!,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'sendTransaction',
params: { transaction: signedXdr },
}),
});
const { result } = await response.json();
return result;
}
// Poll for completion
async function waitForTransaction(hash: string, timeoutMs = 30000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const response = await fetch(STELLAR_RPC_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LUMENQUERY_API_KEY!,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getTransaction',
params: { hash },
}),
});
const { result } = await response.json();
if (result.status === 'SUCCESS') return { success: true, result };
if (result.status === 'FAILED') return { success: false, result };
await new Promise(r => setTimeout(r, 1000));
}
throw new Error('Transaction timeout');
}Verdict: Both work. RPC is slightly faster for submission and better for Soroban transactions. Horizon gives richer response data.
Example 4: Invoking a Smart Contract
Using Stellar RPC (the only option)
import { Contract, Address, nativeToScVal, Networks } from '@stellar/stellar-sdk';
async function invokeContract(
contractId: string,
method: string,
args: any[],
sourceKeypair: Keypair
) {
const contract = new Contract(contractId);
// Build the invocation
const operation = contract.call(
method,
...args.map(arg => nativeToScVal(arg))
);
// Get account for sequence
const accountResponse = await fetch(STELLAR_RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getLedgerEntries',
params: { keys: [createAccountKey(sourceKeypair.publicKey())] },
}),
});
// Build transaction
const transaction = new TransactionBuilder(sourceAccount, {
fee: '1000000',
networkPassphrase: Networks.PUBLIC,
})
.addOperation(operation)
.setTimeout(30)
.build();
// Simulate first
const simResponse = await fetch(STELLAR_RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'simulateTransaction',
params: { transaction: transaction.toXDR() },
}),
});
const { result: simulation } = await simResponse.json();
if (simulation.error) {
throw new Error(`Simulation failed: ${simulation.error}`);
}
// Prepare transaction with simulation results
const preparedTx = SorobanRpc.assembleTransaction(
transaction,
simulation
);
preparedTx.sign(sourceKeypair);
// Submit
const submitResponse = await fetch(STELLAR_RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'sendTransaction',
params: { transaction: preparedTx.toXDR() },
}),
});
return submitResponse.json();
}Verdict: Smart contracts require Stellar RPC for simulation. Horizon can index the results after the fact.
Example 5: Real-Time Streaming
Using Horizon SSE
function streamTransactions(
accountId: string,
onTransaction: (tx: Transaction) => void,
onError: (err: Error) => void
) {
const url = `${HORIZON_URL}/accounts/${accountId}/transactions?cursor=now`;
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
const tx = JSON.parse(event.data);
onTransaction(tx);
};
eventSource.onerror = (error) => {
onError(new Error('Stream connection error'));
};
return () => eventSource.close();
}
// Usage
const stopStreaming = streamTransactions(
'GABC...XYZ',
(tx) => console.log('New transaction:', tx.hash),
(err) => console.error('Stream error:', err)
);
// Later: stopStreaming();Verdict: Horizon is the only option for streaming. Stellar RPC is pull-based.
Architecture Patterns
Pattern 1: Wallet Application
┌─────────────────────────────────────────┐
│ Wallet Frontend │
├─────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ Horizon │ │ Stellar RPC │ │
│ └──────┬──────┘ └───────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ • Account info • Simulate tx │
│ • Tx history • Submit tx │
│ • Stream updates • Contract calls │
│ • Asset metadata • Fee estimates │
│ │
└─────────────────────────────────────────┘Pattern 2: DeFi Application
┌─────────────────────────────────────────┐
│ DeFi Protocol UI │
├─────────────────────────────────────────┤
│ │
│ Stellar RPC (Primary) │
│ • Contract state queries │
│ • Transaction simulation │
│ • Swap execution │
│ • LP position management │
│ │
│ Horizon (Secondary) │
│ • Trade history │
│ • Volume analytics │
│ • Price charts (historical) │
│ │
└─────────────────────────────────────────┘Pattern 3: Block Explorer
┌─────────────────────────────────────────┐
│ Block Explorer │
├─────────────────────────────────────────┤
│ │
│ Horizon (Primary) │
│ • All historical data │
│ • Transaction search │
│ • Account lookup │
│ • Operations & effects │
│ • Asset issuance history │
│ │
│ Stellar RPC (Secondary) │
│ • Contract code viewer │
│ • Contract state inspector │
│ • Real-time ledger info │
│ │
└─────────────────────────────────────────┘Performance Considerations
| Metric | Horizon | Stellar RPC |
|---|---|---|
| Cold query latency | 50-200ms | 20-50ms |
| State query depth | Full history | Current + recent |
| Storage requirements | 1TB+ | ~50GB |
| Memory usage | 8-16GB | 2-4GB |
| Concurrent connections | ~1000 | ~5000 |
Conclusion
Choose your data access layer based on your use case:
The best applications use both services strategically, leveraging each for its strengths.
*Need both Horizon and Stellar RPC with a single API key? LumenQuery provides unified access to the complete Stellar infrastructure stack.*