If you need real-time BTC conversion (USD and BRL), polling an API every few seconds is usually not enough.
A better approach is streaming quotes with WebSockets and calculating conversions as events arrive.
Why WebSockets for BTC conversion?
With WebSockets, your app keeps one open connection and receives new prices instantly.
Benefits:
- Lower latency than polling
- Fewer HTTP requests
- Better user experience for real-time values
Trade-offs:
- You must handle reconnects
- Need heartbeat/health checks
- Must validate and normalize incoming messages
Real-time conversion model
For BTC conversion, a common model is:
- Stream BTC/USD
- Stream USD/BRL
- Calculate BTC/BRL = BTC/USD × USD/BRL
This avoids waiting for a separate BTC/BRL endpoint and keeps conversion logic transparent
What is a “tick”?
A tick is one market update event.
Example: BTCUSD changed to 64210.50 at timestamp t.
In this article, each tick has:
-
pair: market identifier (BTCUSD,USDBRL) -
price: latest value for that pair -
ts: event timestamp
Why this matters: conversion state should always be derived from the latest ticks.
Minimal WebSocket client (TypeScript)
This client only transports responsibilities:
- Connect
- Receive messages
- Parse and normalize into a consistent shape
- Notify listeners
- Reconnect on disconnect
type MarketTick = {
pair: string; // e.g. "BTCUSD" or "USDBRL"
price: number;
ts: number;
};
class WsFeedClient {
private ws?: WebSocket;
private listeners: Array<(tick: MarketTick) => void> = [];
constructor(private readonly url: string) {}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => console.log("[ws] connected");
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(String(event.data));
// Normalize external payload into internal contract
const tick: MarketTick = {
pair: String(data.pair),
price: Number(data.price),
ts: Number(data.ts),
};
// Basic guard
if (!tick.pair || Number.isNaN(tick.price) || Number.isNaN(tick.ts)) return;
this.listeners.forEach((listener) => listener(tick));
} catch {
console.warn("[ws] invalid payload");
}
};
this.ws.onclose = () => {
console.warn("[ws] disconnected, reconnecting...");
setTimeout(() => this.connect(), 1500);
};
this.ws.onerror = () => {
this.ws?.close();
};
}
onTick(listener: (tick: MarketTick) => void) {
this.listeners.push(listener);
}
}
Why normalize messages early?
External providers may use different field names and formats.
If raw payloads leak into business logic, maintenance gets harder quickly.
So we normalize at the edge (MarketTick) and keep the rest of the system provider-agnostic.
Conversion engine (BTC → USD and BRL)
This component stores the latest required rates and computes current outputs.
type ConversionSnapshot = {
btcInUsd: number;
btcInBrl: number | null;
updatedAt: number;
};
class BtcConverter {
private btcUsd?: number;
private usdBrl?: number;
private lastTs?: number;
update(tick: { pair: string; price: number; ts: number }) {
if (tick.pair === "BTCUSD") this.btcUsd = tick.price;
if (tick.pair === "USDBRL") this.usdBrl = tick.price;
this.lastTs = tick.ts;
}
getSnapshot(): ConversionSnapshot | null {
// Can't compute anything without BTCUSD
if (this.btcUsd == null) return null;
return {
btcInUsd: this.btcUsd,
btcInBrl: this.usdBrl == null ? null : this.btcUsd * this.usdBrl,
updatedAt: this.lastTs ?? Date.now(),
};
}
}
What is a “snapshot”?
A snapshot is the current computed view of your conversion state.
It is useful because:
- UI/services consume one stable object
- Internal partial state stays hidden
- Logging and caching become simpler
Instead of exposing raw tick flow everywhere, you expose a single coherent result.
Wiring everything together
Event flow:
WebSocket tick → normalize → update converter → emit snapshot
const client = new WsFeedClient(process.env.MARKET_WS_URL!);
const converter = new BtcConverter();
client.onTick((tick) => {
converter.update(tick);
const snapshot = converter.getSnapshot();
if (!snapshot) return;
console.log("BTC in USD:", snapshot.btcInUsd);
if (snapshot.btcInBrl != null) {
console.log("BTC in BRL:", snapshot.btcInBrl);
} else {
console.log("BTC in BRL: waiting for USDBRL tick...");
}
});
client.connect();
Satlance example (high-level)
In Satlance, this is implemented with clear separation of concerns:
- WebSocket client layer manages connection lifecycle and message intake
- Broadcaster layer distributes normalized price events internally
- Domain layer consumes those events for conversion and product features
This design keeps real-time transport logic isolated from business rules, making the system easier to test, evolve, and operate.

Top comments (0)