Building a Reusable Multi-Exchange WebSocket Price Client in TypeScript

Market dashboards, portfolio lists, and trading screens often need live token prices from several exchanges. Creating a new WebSocket inside every component looks simple, but it quickly causes duplicate connections, inconsistent payloads, updates after unmount, and connections that never recover after a network interruption.

This article builds a reusable TypeScript market data client with the following features:

  • Binance, OKX, and Bybit spot ticker support.
  • One normalized ticker shape for every exchange.
  • One shared connection per exchange across pages and components.
  • Multiple listeners for the same symbol without callback collisions.
  • Reference-counted unsubscription.
  • Exponential-backoff reconnection with automatic resubscription.
  • Heartbeats, error reporting, and explicit resource disposal.

This example reads public market data and does not require API keys. It does not place orders or access accounts. Exchange protocols can change, so verify the official documentation before deploying it.


1. Define One Public Interface

The exchanges use different fields and symbol formats. Binance and Bybit use BTCUSDT, while OKX uses BTC-USDT. UI code should not need to know those details, so our public API accepts BTC/USDT and emits one ticker shape.

export type Exchange = "binance" | "okx" | "bybit"

export interface MarketTicker {
  exchange: Exchange
  symbol: string
  price: number
  bid?: number
  ask?: number
  change24h?: number
  timestamp: number
}

export type TickerListener = (ticker: MarketTicker) => void
export type ErrorListener = (error: Error) => void

change24h is always a percentage. A value of 1.25 means a 1.25% increase, not the decimal ratio 0.0125.


2. The Complete Shared Client

Save the implementation below as lib/market-price-client.ts. It is browser-only; in Next.js, call it from a component or hook marked with "use client".

export type Exchange = "binance" | "okx" | "bybit"

export interface MarketTicker {
  exchange: Exchange
  symbol: string
  price: number
  bid?: number
  ask?: number
  change24h?: number
  timestamp: number
}

export type TickerListener = (ticker: MarketTicker) => void
export type ErrorListener = (error: Error) => void

type JsonRecord = Record<string, unknown>

interface ExchangeAdapter {
  url: string
  toExchangeSymbol(symbol: string): string
  toPublicSymbol(symbol: string): string
  subscribe(symbols: string[]): string
  unsubscribe(symbols: string[]): string
  parse(payload: unknown): MarketTicker | null
  heartbeat?: string
}

const asRecord = (value: unknown): JsonRecord | null =>
  typeof value === "object" && value !== null ? (value as JsonRecord) : null

const text = (value: unknown): string | undefined =>
  typeof value === "string" ? value : undefined

const finiteNumber = (value: unknown): number | undefined => {
  const number = typeof value === "number" ? value : Number(value)
  return Number.isFinite(number) ? number : undefined
}

const normalizePublicSymbol = (symbol: string): string => {
  const normalized = symbol.trim().toUpperCase().replace(/[-_]/g, "/")

  if (!/^[A-Z0-9]+\/[A-Z0-9]+$/.test(normalized)) {
    throw new Error(`Invalid symbol: ${symbol}. Use a value such as BTC/USDT.`)
  }

  return normalized
}

const compactSymbol = (symbol: string) =>
  normalizePublicSymbol(symbol).replace("/", "")

const dashedSymbol = (symbol: string) =>
  normalizePublicSymbol(symbol).replace("/", "-")

const binance: ExchangeAdapter = {
  url: "wss://stream.binance.com:9443/ws",
  toExchangeSymbol: (symbol) => compactSymbol(symbol).toLowerCase(),
  toPublicSymbol: (symbol) => symbol.toUpperCase(),
  subscribe: (symbols) =>
    JSON.stringify({
      method: "SUBSCRIBE",
      params: symbols.map((symbol) => `${symbol}@ticker`),
      id: Date.now(),
    }),
  unsubscribe: (symbols) =>
    JSON.stringify({
      method: "UNSUBSCRIBE",
      params: symbols.map((symbol) => `${symbol}@ticker`),
      id: Date.now(),
    }),
  parse: (payload) => {
    const data = asRecord(payload)
    if (!data || data.e !== "24hrTicker") return null

    const price = finiteNumber(data.c)
    const symbol = text(data.s)
    if (price === undefined || !symbol) return null

    return {
      exchange: "binance",
      symbol,
      price,
      bid: finiteNumber(data.b),
      ask: finiteNumber(data.a),
      change24h: finiteNumber(data.P),
      timestamp: finiteNumber(data.E) ?? Date.now(),
    }
  },
}

const okx: ExchangeAdapter = {
  url: "wss://ws.okx.com:8443/ws/v5/public",
  toExchangeSymbol: dashedSymbol,
  toPublicSymbol: (symbol) => symbol.replace("-", "/"),
  subscribe: (symbols) =>
    JSON.stringify({
      op: "subscribe",
      args: symbols.map((instId) => ({ channel: "tickers", instId })),
    }),
  unsubscribe: (symbols) =>
    JSON.stringify({
      op: "unsubscribe",
      args: symbols.map((instId) => ({ channel: "tickers", instId })),
    }),
  heartbeat: "ping",
  parse: (payload) => {
    const message = asRecord(payload)
    const rows = Array.isArray(message?.data) ? message.data : []
    const data = asRecord(rows[0])
    if (!data) return null

    const price = finiteNumber(data.last)
    const symbol = text(data.instId)
    if (price === undefined || !symbol) return null

    const open = finiteNumber(data.open24h)
    const change24h = open && open !== 0 ? ((price - open) / open) * 100 : undefined

    return {
      exchange: "okx",
      symbol: symbol.replace("-", "/"),
      price,
      bid: finiteNumber(data.bidPx),
      ask: finiteNumber(data.askPx),
      change24h,
      timestamp: finiteNumber(data.ts) ?? Date.now(),
    }
  },
}

const bybit: ExchangeAdapter = {
  url: "wss://stream.bybit.com/v5/public/spot",
  toExchangeSymbol: compactSymbol,
  toPublicSymbol: (symbol) => symbol,
  subscribe: (symbols) =>
    JSON.stringify({
      op: "subscribe",
      args: symbols.map((symbol) => `tickers.${symbol}`),
    }),
  unsubscribe: (symbols) =>
    JSON.stringify({
      op: "unsubscribe",
      args: symbols.map((symbol) => `tickers.${symbol}`),
    }),
  heartbeat: JSON.stringify({ op: "ping" }),
  parse: (payload) => {
    const message = asRecord(payload)
    if (!text(message?.topic)?.startsWith("tickers.")) return null

    const data = asRecord(message?.data)
    const price = finiteNumber(data?.lastPrice)
    const symbol = text(data?.symbol)
    if (price === undefined || !symbol) return null

    const ratio = finiteNumber(data?.price24hPcnt)

    return {
      exchange: "bybit",
      symbol,
      price,
      bid: finiteNumber(data?.bid1Price),
      ask: finiteNumber(data?.ask1Price),
      change24h: ratio === undefined ? undefined : ratio * 100,
      timestamp: finiteNumber(message?.ts) ?? Date.now(),
    }
  },
}

const adapters: Record<Exchange, ExchangeAdapter> = {
  binance,
  okx,
  bybit,
}

class ExchangeConnection {
  private socket: WebSocket | null = null
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null
  private reconnectAttempts = 0
  private manuallyClosed = false
  private readonly listeners = new Map<string, Set<TickerListener>>()

  constructor(
    private readonly exchange: Exchange,
    private readonly adapter: ExchangeAdapter,
    private readonly onError?: ErrorListener,
  ) {}

  add(symbol: string, listener: TickerListener): () => void {
    const publicSymbol = normalizePublicSymbol(symbol)
    const exchangeSymbol = this.adapter.toExchangeSymbol(publicSymbol)
    const listeners = this.listeners.get(exchangeSymbol) ?? new Set<TickerListener>()
    const isFirstListener = listeners.size === 0

    listeners.add(listener)
    this.listeners.set(exchangeSymbol, listeners)
    this.manuallyClosed = false

    if (!this.socket || this.socket.readyState === WebSocket.CLOSED) {
      this.connect()
    } else if (isFirstListener && this.socket.readyState === WebSocket.OPEN) {
      this.socket.send(this.adapter.subscribe([exchangeSymbol]))
    }

    let active = true
    return () => {
      if (!active) return
      active = false
      this.remove(exchangeSymbol, listener)
    }
  }

  close(): void {
    this.manuallyClosed = true
    this.clearTimers()
    this.listeners.clear()
    this.socket?.close(1000, "Client disposed")
    this.socket = null
  }

  private connect(): void {
    if (typeof window === "undefined") {
      this.report(new Error("MarketPriceClient can only run in the browser."))
      return
    }

    if (
      this.socket?.readyState === WebSocket.OPEN ||
      this.socket?.readyState === WebSocket.CONNECTING
    ) {
      return
    }

    const socket = new WebSocket(this.adapter.url)
    this.socket = socket

    socket.onopen = () => {
      this.reconnectAttempts = 0
      const symbols = [...this.listeners.keys()]
      if (symbols.length > 0) socket.send(this.adapter.subscribe(symbols))
      this.startHeartbeat()
    }

    socket.onmessage = (event) => {
      if (event.data === "pong") return

      try {
        const ticker = this.adapter.parse(JSON.parse(String(event.data)))
        if (!ticker) return

        const exchangeSymbol = this.adapter.toExchangeSymbol(ticker.symbol)
        const normalizedTicker = {
          ...ticker,
          symbol: normalizePublicSymbol(
            this.adapter.toPublicSymbol(exchangeSymbol),
          ),
        }

        this.listeners
          .get(exchangeSymbol)
          ?.forEach((listener) => listener(normalizedTicker))
      } catch (error) {
        this.report(error)
      }
    }

    socket.onerror = () => {
      this.report(new Error(`${this.exchange} WebSocket error.`))
    }

    socket.onclose = () => {
      this.stopHeartbeat()
      this.socket = null

      if (!this.manuallyClosed && this.listeners.size > 0) {
        this.scheduleReconnect()
      }
    }
  }

  private remove(symbol: string, listener: TickerListener): void {
    const listeners = this.listeners.get(symbol)
    if (!listeners) return

    listeners.delete(listener)
    if (listeners.size > 0) return

    this.listeners.delete(symbol)
    if (this.socket?.readyState === WebSocket.OPEN) {
      this.socket.send(this.adapter.unsubscribe([symbol]))
    }
  }

  private scheduleReconnect(): void {
    if (this.reconnectTimer) return

    const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30_000)
    this.reconnectAttempts += 1
    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null
      this.connect()
    }, delay)
  }

  private startHeartbeat(): void {
    this.stopHeartbeat()
    if (!this.adapter.heartbeat) return

    this.heartbeatTimer = setInterval(() => {
      if (this.socket?.readyState === WebSocket.OPEN) {
        this.socket.send(this.adapter.heartbeat)
      }
    }, 20_000)
  }

  private stopHeartbeat(): void {
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
    this.heartbeatTimer = null
  }

  private clearTimers(): void {
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
    this.reconnectTimer = null
    this.stopHeartbeat()
  }

  private report(error: unknown): void {
    this.onError?.(
      error instanceof Error ? error : new Error("Unknown WebSocket error."),
    )
  }
}

export class MarketPriceClient {
  private readonly connections = new Map<Exchange, ExchangeConnection>()

  constructor(private readonly onError?: ErrorListener) {}

  subscribe(
    exchange: Exchange,
    symbol: string,
    listener: TickerListener,
  ): () => void {
    let connection = this.connections.get(exchange)

    if (!connection) {
      connection = new ExchangeConnection(
        exchange,
        adapters[exchange],
        this.onError,
      )
      this.connections.set(exchange, connection)
    }

    return connection.add(symbol, listener)
  }

  dispose(): void {
    this.connections.forEach((connection) => connection.close())
    this.connections.clear()
  }
}

export const marketPriceClient = new MarketPriceClient((error) => {
  console.error("[market-price]", error)
})

3. Why It Can Be Used in Multiple Places

marketPriceClient is a module-level singleton. Every component importing it within the same browser tab receives the same instance.

Subscriptions are managed in two layers:

MarketPriceClient
  ├─ Binance connection
  │    ├─ BTCUSDT -> listener A, listener B
  │    └─ ETHUSDT -> listener C
  ├─ OKX connection
  └─ Bybit connection

Each call to subscribe() returns a cleanup function. If component A unsubscribes, only A's callback is removed. The underlying exchange subscription remains active while component B still listens to the same pair.


4. Using It with React and Next.js

Wrap the client in a hook for convenient use from any client component:

"use client"

import { useEffect, useState } from "react"
import {
  marketPriceClient,
  type Exchange,
  type MarketTicker,
} from "@/lib/market-price-client"

export function useMarketPrice(exchange: Exchange, symbol: string) {
  const [ticker, setTicker] = useState<MarketTicker | null>(null)

  useEffect(() => {
    return marketPriceClient.subscribe(exchange, symbol, setTicker)
  }, [exchange, symbol])

  return ticker
}

Use it in a price card:

"use client"

import { useMarketPrice } from "@/hooks/use-market-price"

export function BitcoinPrice() {
  const ticker = useMarketPrice("binance", "BTC/USDT")

  if (!ticker) return <span>Loading...</span>

  return (
    <div>
      <strong>{ticker.exchange}</strong>
      <span>{ticker.symbol}: {ticker.price}</span>
      <span>24h: {ticker.change24h?.toFixed(2)}%</span>
    </div>
  )
}

You can compare several exchanges on one page:

const binanceTicker = useMarketPrice("binance", "BTC/USDT")
const okxTicker = useMarketPrice("okx", "BTC/USDT")
const bybitTicker = useMarketPrice("bybit", "BTC/USDT")

The same API also works outside React:

const unsubscribe = marketPriceClient.subscribe(
  "okx",
  "ETH/USDT",
  (ticker) => console.log(ticker.price),
)

// Call this when the price is no longer needed.
unsubscribe()

Do not normally call marketPriceClient.dispose() when an ordinary component unmounts because it closes connections shared by the entire application. Reserve it for application shutdown, a logout flow that no longer needs market data, or test cleanup.


5. Production Considerations

5.1 WebSocket Events Are Not Persistent State

Messages sent while the client is disconnected are lost. A price display can usually wait for the next event after reconnection. If an initial value must be available immediately, fetch the exchange's REST ticker first and then apply WebSocket updates.

5.2 Background Browser Tabs Are Throttled

Browsers may reduce timer frequency in background tabs. A high-reliability market system should maintain exchange connections on a server and distribute normalized data to browsers through its own WebSocket or SSE endpoint.

5.3 Do Not Subscribe Without Limits

Exchanges impose connection, message-rate, and per-connection subscription limits. Large market lists should batch subscriptions, rate-limit resubscription, and split connections according to current official limits.

5.4 Numeric Precision Matters

The example uses number for convenient display. Settlement, order placement, and exact monetary calculations should preserve the strings returned by the exchange and use a decimal library such as decimal.js or big.js.

5.5 Symbol Mapping Eventually Needs Instrument Metadata

String conversion is adequate for common pairs such as BTC/USDT. Production systems should periodically load each exchange's instruments or exchange-info endpoint, maintain an explicit mapping, and exclude delisted or suspended instruments.

5.6 A Ticker Is Not a Guaranteed Execution Price

The last trade, best bid, and best ask represent different values. Larger orders are also affected by depth, slippage, and fees. Market data displays should not be presented as investment advice or an execution guarantee.


6. Summary

A reusable multi-exchange WebSocket wrapper must do more than receive a price. It needs protocol adapters, shared connections, correct subscription lifecycles, reconnection, and resource cleanup.

The implementation in this article normalizes Binance, OKX, and Bybit spot tickers into MarketTicker. It carries several symbols over one connection per exchange, lets multiple components share one subscription through listener sets, and uses cleanup callbacks, heartbeats, and exponential-backoff reconnection for long-running stability.

The business layer only needs one entry point:

const unsubscribe = marketPriceClient.subscribe(
  "binance",
  "BTC/USDT",
  (ticker) => console.log(ticker.price),
)

Adding another exchange only requires a new ExchangeAdapter; UI components and the normalized data model remain unchanged. That separation between exchange protocols and presentation code is the main value of the design.