DEV Community

Rubem Vasconcelos
Rubem Vasconcelos

Posted on

Using WebSockets to Convert BTC to USD and Reais (BRL)

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:

  1. Connect
  2. Receive messages
  3. Parse and normalize into a consistent shape
  4. Notify listeners
  5. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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(),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Satlance example (high-level)

In Satlance, this is implemented with clear separation of concerns:

  1. WebSocket client layer manages connection lifecycle and message intake
  2. Broadcaster layer distributes normalized price events internally
  3. 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)