How to Build a Crypto / Stock Price Ticker with WebSockets
Build a live price ticker that updates in real time — polling an external price feed on the server and pushing updates to all clients instantly via WebSockets.
Price data is one of the most natural fits for real-time WebSocket infrastructure. Users expect numbers to move. When they do not, the experience feels broken. But getting live prices into the browser efficiently is subtler than it first appears.
The Problem with Client-Side Polling
The naive approach is to have each browser tab hit the price API on a timer:
setInterval(async () => {
const res = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd");
const data = await res.json();
updateUI(data);
}, 1000);
This works for one user. It falls apart at scale. If you have 500 concurrent users and each tab polls every second, you are firing 500 outbound HTTP requests per second toward the price API — most of which are fetching identical data. Every major price API has rate limits. You will hit them, your users will start seeing stale or missing data, and you will be burning API quota (and potentially money) on redundant work.
The deeper issue is that the data does not change per-user. Bitcoin's price is the same for every tab. Fetching it 500 times per second when once per second is enough is pure waste.
The Better Architecture: One Poller, Many Subscribers
The fix is to centralize the polling on your server and fan the result out to every connected client over WebSockets:
External Price API
|
(1 request/sec)
|
Your Server
|
Apinator (WebSocket broadcast)
|
┌─────┼─────┐
Tab A Tab B Tab C ...
Your server polls once, regardless of how many clients are watching. When a price changes, it publishes a single event to Apinator, which fans it out to every subscriber instantly. You go from O(N) outbound API calls per second to O(1).
Server Setup
Install the Apinator server SDK:
npm install @apinator/server
Then create a polling loop that fetches prices and publishes changes:
// server/ticker.js
import Apinator from "@apinator/server";
const client = new Apinator({
appId: process.env.APINATOR_APP_ID,
key: process.env.APINATOR_KEY,
secret: process.env.APINATOR_SECRET,
});
// Track last known prices so we only publish on change
const lastPrices = {};
const SYMBOLS = ["bitcoin", "ethereum", "solana"];
async function fetchPrices() {
const ids = SYMBOLS.join(",");
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`;
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
for (const [symbol, values] of Object.entries(data)) {
const price = values.usd;
const previous = lastPrices[symbol];
// Delta check: only publish when the price actually changed
if (price !== previous) {
lastPrices[symbol] = price;
await client.trigger(`prices`, "price.update", {
symbol,
price,
previous: previous ?? price,
direction: previous == null ? "flat" : price > previous ? "up" : "down",
timestamp: Date.now(),
});
}
}
}
// Poll every second
setInterval(fetchPrices, 1000);
fetchPrices(); // kick off immediately
console.log("Ticker running");
A few things to note here. The lastPrices map is the key to keeping your WebSocket traffic lean. If Bitcoin holds at $67,432 for five seconds, no events are published during those five seconds. Your clients keep displaying the last known value without any chatter on the wire. Only when the price moves does an event go out.
The direction field lets the client know whether to flash green or red without having to compute it client-side.
Client Setup
Install the Apinator client SDK:
npm install @apinator/sdk
Subscribe to the prices channel and bind the price.update event:
// client/ticker.js
import { RealtimeClient } from "@apinator/sdk";
const realtime = new RealtimeClient("YOUR_APINATOR_KEY");
const channel = realtime.subscribe("prices");
channel.bind("price.update", ({ symbol, price, previous, direction }) => {
const el = document.getElementById(`ticker-${symbol}`);
if (!el) return;
el.textContent = `$${price.toLocaleString()}`;
// Flash animation based on direction
el.classList.remove("flash-up", "flash-down");
void el.offsetWidth; // force reflow so removing and re-adding works
if (direction === "up") el.classList.add("flash-up");
if (direction === "down") el.classList.add("flash-down");
});
The corresponding HTML and CSS for the color flash:
<div class="ticker">
<span class="label">Bitcoin</span>
<span id="ticker-bitcoin" class="price">—</span>
</div>
<div class="ticker">
<span class="label">Ethereum</span>
<span id="ticker-ethereum" class="price">—</span>
</div>
<div class="ticker">
<span class="label">Solana</span>
<span id="ticker-solana" class="price">—</span>
</div>
.price {
transition: color 0.3s ease;
}
@keyframes flash-green {
0% { background-color: rgba(34, 197, 94, 0.3); }
100% { background-color: transparent; }
}
@keyframes flash-red {
0% { background-color: rgba(239, 68, 68, 0.3); }
100% { background-color: transparent; }
}
.flash-up {
animation: flash-green 0.6s ease-out forwards;
color: #16a34a;
}
.flash-down {
animation: flash-red 0.6s ease-out forwards;
color: #dc2626;
}
The void el.offsetWidth trick forces the browser to reflow before re-adding the animation class, so the flash plays every time even if the same direction fires back to back.
One Channel vs One Channel Per Symbol
The example above uses a single prices channel and includes the symbol field in each event payload. This is fine when you have a small number of symbols and all clients want all prices.
If clients only care about specific symbols — say, a user has configured a watchlist — you can use one channel per symbol instead:
// Server: publish to a per-symbol channel
await client.trigger(`prices.${symbol}`, "price.update", { price, direction });
// Client: subscribe only to symbols the user cares about
const watchlist = ["bitcoin", "ethereum"];
for (const symbol of watchlist) {
const ch = realtime.subscribe(`prices.${symbol}`);
ch.bind("price.update", ({ price, direction }) => renderTick(symbol, price, direction));
}
The trade-off: per-symbol channels let clients subscribe precisely to what they need, reducing unnecessary event delivery. A single prices channel is simpler to manage and works well when most clients want most symbols.
Rate Limiting and Noisy Feeds
The delta check shown above handles the common case. For feeds that are inherently noisy — tick-level stock data where the price changes on virtually every poll — you may want to throttle publishing to a minimum interval:
const lastPublished = {};
const MIN_PUBLISH_INTERVAL_MS = 500;
function shouldPublish(symbol, price) {
const priceChanged = price !== lastPrices[symbol];
const now = Date.now();
const enoughTimeElapsed = (now - (lastPublished[symbol] ?? 0)) >= MIN_PUBLISH_INTERVAL_MS;
return priceChanged && enoughTimeElapsed;
}
This ensures you never publish the same symbol more than twice per second even on a volatile feed, keeping WebSocket traffic predictable under load.
WebSockets vs Server-Sent Events
For a price ticker specifically, the data flow is entirely one-directional: server pushes to clients, clients never send anything back. Server-Sent Events (SSE) would technically work here. SSE is simpler to implement on the server side (plain HTTP chunked responses) and reconnects automatically.
That said, WebSockets are the right choice for most production scenarios:
- Multiplexing: a single WebSocket connection handles all your channels. With SSE you need one connection per event stream.
- Presence and auth: if you later want authenticated or presence channels (e.g., showing which users are watching a particular stock), WebSockets support those patterns natively. SSE does not.
- Bidirectional ready: if you add features like alerts ("notify me when BTC crosses $70k"), you already have the channel to send that preference back to the server.
- Broader infrastructure support: managed WebSocket platforms like Apinator handle reconnection, scaling, and fanout for you. Rolling equivalent infrastructure for SSE at scale requires more custom work.
Use SSE if you are building a tiny, self-contained feed with no plans to extend it. Use WebSockets when you want a solid foundation for everything real-time in your application.
Wrapping Up
The pattern here generalizes beyond crypto prices to any broadcast data: sports scores, order book updates, live auction bids, sensor readings. The key insight is always the same — centralize the data source on the server, push changes out via WebSockets, and let the infrastructure handle fanout. Your external API sees a constant one request per second; your clients see updates in real time regardless of how many of them are watching.
The full example above is minimal but production-ready in structure. Add error handling around the fetch call, graceful reconnection logic on the client (Apinator's SDK handles this automatically), and you have a ticker that scales to thousands of concurrent users without breaking a sweat.