Most teams building crypto price feeds reach for WebSockets. It feels right: real-time data needs real-time protocols. But WebSockets are overkill for 80% of crypto price feeds. If your data flows one direction (server → client), Server-Sent Events (SSE) is simpler, more reliable, and works in 10 lines of code.
I've seen teams spend three days debugging WebSocket reconnection logic. SSE handles reconnections automatically. I'll show you why SSE might be the better choice for your crypto app.
What is SSE (and why it matters for crypto feeds)
Server-Sent Events is an HTTP-based protocol where the server holds a connection open and pushes data to the client whenever it has updates. Think of it like a one-way radio broadcast: the server talks, the client listens.
For crypto price feeds, that's exactly what you need. Prices update on the server (from DEX aggregators, oracles, or market data), and you push those updates to connected clients. You don't need clients sending data back to the server for price feeds.
How SSE works under the hood:
The client makes a standard HTTP GET request with Accept: text/event-stream. The server responds with 200 OK and a special Content-Type: text/event-stream header. The connection stays open, and the server writes newline-delimited messages whenever it has updates.
The message format is simple:
data: {"token":"WETH","price":"3245.67"}
data: {"token":"USDC","price":"0.9998"}
Each message starts with data:, followed by your payload (usually JSON), then two newlines (\n\n). No binary protocols, no handshake complexity, just HTTP and text.
What makes SSE good for crypto feeds:
- Automatic reconnection - Browser handles reconnects with exponential backoff built-in
-
Event IDs for recovery - Server can send
id:field, browser remembers last ID and resumes from there - Works through proxies - It's just HTTP, so corporate firewalls and CDNs pass it through
-
No library needed -
EventSourceAPI is built into every modern browser -
Simpler debugging - You can test with
curl -N, messages are readable text
What SSE can't do (and why that's fine):
- Client can't send messages back through the SSE connection (use regular HTTP POST if needed)
- Binary data requires base64 encoding (WebSocket handles binary natively)
- Not designed for bidirectional protocols (game state, collaborative editing)
For crypto price feeds, you rarely need the client to send data upstream through the same connection. Watching prices is a one-way flow. When a user wants to execute a trade, that's a separate HTTP POST to your trading API. SSE + REST covers 90% of crypto app needs.
Your first SSE connection (10 lines with DexPaprika)
Let's connect to a real crypto price feed. We'll use DexPaprika's free SSE endpoint: no credit card, no API key, just working code.
Goal: Stream live Ethereum WETH prices
const eventSource = new EventSource(
'https://streaming.dexpaprika.com/stream?method=t_p&chain=ethereum&address=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'
);
eventSource.addEventListener('t_p', (event) => {
const data = JSON.parse(event.data);
console.log(`${data.c}: $${data.p} at ${new Date(data.t * 1000).toLocaleTimeString()}`);
});
eventSource.onerror = (error) => {
console.error('Connection error:', error);
// Browser automatically reconnects
};
What this does:
-
new EventSource(url)- Opens HTTP connection to DexPaprika's streaming endpoint -
addEventListener('t_p', ...)- Listens for price events (DexPaprika sendsevent: t_p) -
JSON.parse(event.data)- Parse the price payload -
onerror- Handle connection errors (browser will retry automatically)
The URL breakdown:
-
streaming.dexpaprika.com/stream- DexPaprika's SSE endpoint -
method=t_p- Token price method -
chain=ethereum- Which blockchain -
address=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2- WETH contract address
Run this in your browser console (F12) and you'll see live prices:
ethereum: $3245.67 at 2:34:12 PM
ethereum: $3246.01 at 2:34:13 PM
ethereum: $3245.89 at 2:34:14 PM
Ten lines of code, no authentication, no complex setup. The connection stays open, and prices update about once per second.
Compare this to WebSocket complexity:
// WebSocket version (25+ lines for same functionality)
const ws = new WebSocket('wss://example.com/stream');
ws.onopen = () => {
ws.send(JSON.stringify({
action: 'subscribe',
channel: 'prices',
token: 'WETH'
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data.price);
};
ws.onerror = (error) => {
console.error(error);
// Manual reconnection logic needed
setTimeout(() => {
// Reconnect logic here (10+ more lines)
}, 1000);
};
ws.onclose = () => {
// Also need manual reconnection here
};
With WebSocket, you have to:
- Send a subscription message after connecting
- Write your own reconnection logic (exponential backoff, max retries)
- Handle state recovery (what was I subscribed to?)
- Debug binary frames or JSON over WebSocket protocol
With SSE, the browser does all of that for you.
Handling reconnects and errors in production
The browser's automatic reconnection is good, but production apps need more control. You want exponential backoff, connection state tracking, and graceful degradation.
Production-ready SSE wrapper:
class ResilientSSE {
constructor(url, options = {}) {
this.url = url;
this.eventSource = null;
this.reconnectDelay = 1000; // Start at 1 second
this.maxReconnectDelay = 30000; // Max 30 seconds
this.reconnectAttempts = 0;
this.listeners = new Map();
this.onConnect = options.onConnect || (() => {});
this.onError = options.onError || (() => {});
}
connect() {
this.eventSource = new EventSource(this.url);
this.eventSource.onopen = () => {
console.log('SSE connected');
this.reconnectDelay = 1000; // Reset backoff
this.reconnectAttempts = 0;
this.onConnect();
};
this.eventSource.onerror = (error) => {
console.error('SSE error:', error);
this.onError(error);
// Browser will reconnect automatically, but we track attempts
this.reconnectAttempts++;
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max)
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
this.maxReconnectDelay
);
console.log(`Will reconnect in ${this.reconnectDelay}ms (attempt ${this.reconnectAttempts})`);
};
// Re-attach all listeners
for (const [eventType, handler] of this.listeners) {
this.eventSource.addEventListener(eventType, handler);
}
}
on(eventType, handler) {
this.listeners.set(eventType, handler);
if (this.eventSource) {
this.eventSource.addEventListener(eventType, handler);
}
}
close() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
Using the wrapper:
const priceStream = new ResilientSSE(
'https://streaming.dexpaprika.com/stream?method=t_p&chain=ethereum&address=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
{
onConnect: () => {
console.log('Price feed connected');
updateUI('connected');
},
onError: (error) => {
console.error('Price feed error:', error);
updateUI('reconnecting');
}
}
);
priceStream.on('t_p', (event) => {
const data = JSON.parse(event.data);
updatePriceDisplay(data.c, data.p);
});
priceStream.connect();
This wrapper gives you:
- Exponential backoff (1s → 2s → 4s → 8s → 16s → 30s max)
- Connection state callbacks (show "reconnecting" in UI)
- Listener management (re-attach handlers on reconnect)
- Clean shutdown (close connections properly)
Why exponential backoff matters:
Without backoff, if your server restarts and 10,000 clients all reconnect instantly, you create a "thundering herd." Your server gets slammed with 10,000 simultaneous connections and crashes again. I've seen this cause 15+ minute outages.
With exponential backoff and jitter (random delay), those 10,000 clients reconnect over 30 seconds instead of 100ms. Server stays stable, users get their data back faster.
Streaming multiple assets (scaling to hundreds of tokens)
One EventSource connection handles one stream. For a single token, that's perfect. But what if you're building a portfolio tracker with 50 tokens? Opening 50 separate SSE connections is inefficient.
DexPaprika's POST endpoint lets you subscribe to multiple assets in one stream:
async function streamMultipleAssets(tokens) {
const response = await fetch('https://streaming.dexpaprika.com/stream', {
method: 'POST',
headers: {
'Accept': 'text/event-stream',
'Content-Type': 'application/json'
},
body: JSON.stringify(tokens)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split('\n');
// Keep last incomplete line in buffer
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
handlePriceUpdate(data);
}
}
}
}
// Subscribe to 3 tokens across different chains
streamMultipleAssets([
{chain: 'ethereum', address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', method: 't_p'}, // WETH
{chain: 'solana', address: 'So11111111111111111111111111111111111111112', method: 't_p'}, // SOL
{chain: 'ethereum', address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', method: 't_p'} // USDC
]);
function handlePriceUpdate(data) {
console.log(`${data.c} on ${data.chain}: $${data.p}`);
updatePortfolio(data.a, data.p);
}
Key differences from GET method:
-
Use
fetch()instead ofEventSource-EventSourceAPI only supports GET - Parse the stream manually - Read response.body as a stream, split by newlines
- Handle incomplete messages - Keep a buffer for partial lines
- Single connection for all assets - Much more efficient than N connections
Limits and best practices:
- Max 2,000 tokens per request (DexPaprika limit)
- Recommended: 100-500 tokens per request (better performance and error recovery)
- All assets must be valid - If ANY token address is wrong, entire stream fails with 400 error
If you need 1,500 tokens, split into 3 requests of 500 each. You get:
- Better load distribution across DexPaprika servers
- Partial success (2 streams work even if 1 fails)
- Easier debugging (smaller payload per connection)
Browser quirks and production failures (what actually breaks)
SSE is simple, but production has edge cases. I researched real failures from Stack Overflow, GitHub issues, and engineering blogs. Here are the patterns that actually break in production.
1. Proxy buffering (5-10% of corporate users hit this)
Symptom: Some users report prices being "stuck" for 20+ minutes, then suddenly jumping.
Root cause: HTTP proxies are allowed to buffer responses. Some corporate proxies buffer the entire text/event-stream response, not releasing data to the client until the connection closes.
Detection:
let lastUpdate = Date.now();
priceStream.on('t_p', (event) => {
const delay = Date.now() - lastUpdate;
if (delay > 10000) {
console.warn(`Delayed update: ${delay}ms (proxy buffering?)`);
reportTelemetry('sse_delayed_update', {delay});
}
lastUpdate = Date.now();
});
Solution: Offer a long-polling fallback for users with delays > 10 seconds:
if (detectedProxyBuffering) {
// Fall back to polling every 5 seconds
setInterval(() => {
fetch('/api/prices').then(res => res.json()).then(updatePrices);
}, 5000);
}
2. Browser connection limit (hits users opening 7+ tabs)
Symptom: Opening a 7th tab of your app causes the first 6 tabs to stop receiving updates.
Root cause: Browsers limit HTTP/1.1 connections to 6 per domain (Chrome, Firefox, Safari). EventSource uses one connection per stream. Opening 7 tabs = 7 connections = limit exceeded.
Solution options:
Option A: Use HTTP/2 (supports 100+ concurrent streams):
// Server must support HTTP/2
// No client-side changes needed, just deploy with HTTP/2 enabled
Option B: Share connection across tabs with BroadcastChannel:
const channel = new BroadcastChannel('price_updates');
let isLeaderTab = false;
// Leader election: first tab becomes leader
if (!localStorage.getItem('leader_tab')) {
localStorage.setItem('leader_tab', Date.now());
isLeaderTab = true;
}
if (isLeaderTab) {
// Only leader tab opens SSE connection
priceStream.on('t_p', (event) => {
channel.postMessage(event.data);
});
} else {
// Follower tabs listen to BroadcastChannel
channel.onmessage = (event) => {
const data = JSON.parse(event.data);
updatePriceDisplay(data.c, data.p);
};
}
3. nginx proxy timeout (100% of users behind default nginx config)
Symptom: SSE connection drops exactly every 60 seconds, then reconnects.
Root cause: nginx default proxy_read_timeout is 60 seconds. If no data flows for 60s, nginx closes the connection.
Server-side fix (nginx configuration):
location /stream {
proxy_pass http://backend;
proxy_read_timeout 3600s; # 1 hour
proxy_buffering off; # Critical for SSE
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
Client-side workaround: If you don't control the proxy, send heartbeat comments every 30 seconds:
// Server sends this every 30 seconds
: heartbeat
// Comments (lines starting with :) are ignored by EventSource
// But they keep the connection alive through proxies
4. Safari mobile idle timeout (iOS battery optimization)
Symptom: On iOS, SSE connections close after ~5 minutes when the app is in background.
Root cause: Safari closes idle network connections to save battery.
Solution: Accept this limitation and reconnect when app returns to foreground:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !priceStream.eventSource) {
priceStream.connect();
}
});
When SSE makes sense (decision framework)
Use SSE when:
| Criteria | Why SSE wins |
|---|---|
| Data flows server → client only | Built for this pattern |
| You want automatic reconnection | Browser handles it |
| You're behind corporate proxies/firewalls | Just HTTP, no WebSocket upgrade negotiation |
| You want to test with curl |
curl -N url shows live events |
| Team is learning real-time | Simpler than WebSocket |
Use WebSocket when:
| Criteria | Why WebSocket wins |
|---|---|
| Bidirectional communication needed | SSE is one-way only |
| You need binary data | SSE is text-based (base64 overhead for binary) |
| Sub-second latency critical | WebSocket has slightly lower overhead |
| Mobile apps (not web) | Native WebSocket support often better |
For crypto price feeds specifically:
Most crypto apps fall into SSE territory:
- Prices flow server → client (one-way)
- Updates every 1-3 seconds are fine (not sub-100ms)
- Users watch prices, execute trades via separate API calls (not bidirectional)
- Developer experience matters (SSE is simpler to implement and debug)
I'd estimate 80% of crypto price feed use cases are better served by SSE than WebSocket. The 20% that need WebSocket are usually trading platforms where users send order updates upstream through the same connection.
Summary: SSE is simpler than you think
Server-Sent Events gives you real-time crypto price feeds without WebSocket complexity:
- 10 lines of code to connect to a live stream (no library needed)
- Automatic reconnection with exponential backoff (browser handles it)
- Works through proxies (it's just HTTP)
- Free with DexPaprika (no credit card, no API key)
The production failures are manageable:
- Add heartbeats every 30s (nginx timeout fix)
- Detect proxy buffering, fall back to polling if needed
- Use HTTP/2 or BroadcastChannel for multi-tab apps
- Accept iOS background limitations (reconnect on foreground)
For most crypto apps, SSE + REST covers everything you need. When a user wants to watch prices, use SSE. When they want to execute a trade, use a regular HTTP POST. You don't need full-duplex bidirectional communication for 80% of use cases.
Try DexPaprika's free SSE endpoint:
- Documentation: https://docs.dexpaprika.com/streaming
- Single asset:
https://streaming.dexpaprika.com/stream?method=t_p&chain=ethereum&address=0x... - Multiple assets: POST to
https://streaming.dexpaprika.com/stream - 1,300+ tokens across 20+ chains
- No authentication required
Start with the 10-line example from this article. Add the production wrapper when you need reconnection control. Use the POST method when tracking multiple tokens. You'll have a production-ready crypto price feed running in an afternoon.
FAQ
Q: Can I use EventSource with authentication headers?
No, the EventSource API doesn't support custom headers. If you need authentication, use fetch() with Accept: text/event-stream and parse the stream manually (like the multiple-asset example). Or pass auth tokens as URL query parameters (less secure, but works with EventSource).
Q: How do I know if SSE is working behind my corporate proxy?
Connect to a test stream and log the time between updates. If you see 20+ second gaps when expecting 1-second updates, you're likely hitting proxy buffering. Check navigator.connection.effectiveType and fall back to polling for users on slow/buffered connections.
Q: What happens if my server restarts?
The client's SSE connection breaks, the browser automatically reconnects with exponential backoff. If your server sends id: <message_id> with each event, the browser includes Last-Event-ID header on reconnection. Your server can resume from that ID instead of sending all historical data.
Q: Can I send binary data over SSE?
SSE is text-based. You can base64-encode binary data, but that adds 33% overhead. For crypto price feeds (JSON objects with numbers), this doesn't matter. For chart data or large datasets, consider WebSocket or HTTP/2 Server Push.
Q: Is SSE supported in all browsers?
Yes, in all modern browsers (Chrome, Firefox, Safari, Edge). Check current browser compatibility on MDN. Internet Explorer 11 doesn't support it, but IE11 market share is < 1% in 2026. For legacy support, use a polyfill or fall back to long polling.
Disclosure
I work at CoinPaprika and DexPaprika. All code examples in this article use DexPaprika's SSE streaming endpoint because it's genuinely the best way to demonstrate SSE for crypto prices: it's free, requires no authentication, and covers 1,300+ tokens across 20+ chains.
I've tested competitors (several charge $500-5,000/month for similar streaming). DexPaprika's "completely free, no credit card" approach is why I use it in tutorials.
The technical advice in this article (SSE mechanics, browser quirks, production failures) applies regardless of which API you use.
Top comments (0)