DEV Community

wang vball
wang vball

Posted on

Forex WebSocket Integration: Stream EUR/USD Quotes with Node.js and Recover After Disconnects

Forex WebSocket Integration: Stream EUR/USD Quotes with Node.js and Recover After Disconnects

Abstract: A live forex display needs a client that authenticates, subscribes to the right quote channel, and restores that subscription after a disconnect. A server-side Node.js collector can do this while keeping credentials out of the browser and tracking connection health separately from quote freshness. The implementation below uses documented EUR/USD bid/ask fields, validates incoming messages, and checks an application-level heartbeat; reconnecting resumes collection but does not recover missing ticks.

A useful first integration has a narrow output: one JSON record for each accepted EUR/USD quote, with bid, ask, calculated midpoint, spread, source timestamp, and local receipt time. Once that collector behaves correctly, a dashboard or alert service can consume the same records without handling the provider's wire protocol itself.

This tutorial uses TickDB as the concrete forex input. Its documented ticker channel includes EUR/USD bid and ask fields, and its WebSocket connection authenticates with an API key in the connection URL. The implementation is a server-side example. Keep the key in the collector's environment and keep full connection URLs out of logs.

1. Choose the quote stream before writing the client

A forex WebSocket is a persistent transport for a provider's messages. It does not define a universal subscription format. Tiingo's forex documentation describes top-of-book updates with an array payload; Massive documents separate forex feeds for quotes and aggregate bars. Their message contracts are not interchangeable.

For a bid/ask display, start with quotes. A bar contains a summary over a time interval and cannot replace the current two-sided quote. Keep bid and ask separate in your application model: a calculated midpoint is useful for display, but it is not an executable price. Likewise, do not relabel a provider's latest-price field as a transaction price without confirming its meaning.

Before running the example, have Node.js, npm, and an API key with permission for the requested market and channel. Confirm subscription and connection limits for your account. A successful WebSocket handshake alone does not establish that the requested subscription has been accepted.

2. Connect, subscribe, and normalize one currency pair

The collector connects over TLS, sends a ticker subscription for EURUSD, then routes each incoming message by cmd. A pong updates connection health, an error stops the example for inspection, and a ticker is passed to the quote parser. Keeping these paths separate prevents control messages from being treated as prices.

Figure 1. Suggested integration architecture. The example implements the collector; the dashboard or alert consumer is a later integration step.

Create a project directory and install the WebSocket library:

mkdir forex-websocket-demo
cd forex-websocket-demo
npm init -y
npm install ws@8
Enter fullscreen mode Exit fullscreen mode

Save the following complete program as forex_ws.cjs. The .cjs extension makes the CommonJS module format explicit, even inside a project that otherwise uses ES modules.

const WebSocket = require('ws');

function normalize(d) {
  if (!d || d.symbol !== 'EURUSD' || d.type !== 'forex') return null;
  const price = v => (typeof v === 'string' && v.trim()) ||
    typeof v === 'number' ? Number(v) : NaN;
  const bid = price(d.bid_price), ask = price(d.ask_price);
  const t = d.timestamp;
  if (![bid, ask].every(Number.isFinite) || bid <= 0 || ask < bid ||
      !Number.isSafeInteger(t) || t <= 0 || t > 8.64e15) return null;
  return { symbol: d.symbol, bid, ask, mid: (bid + ask) / 2,
    spread: ask - bid, event_ms: t, received_ms: Date.now() };
}

function start(apiKey, options = {}) {
  const url = new URL(options.url || 'wss://api.tickdb.ai/v1/realtime');
  url.searchParams.set('api_key', apiKey);
  let socket, retry, attempts = 0, stopped = false;
  const emit = options.emit || (row => console.log(JSON.stringify(row)));
  const log = options.log || (text => console.error(text));
  const delay = options.delay || (n => n <= 3 ? 1000 : n <= 6 ? 5000 : 30000);
  function connect() {
    if (stopped) return;
    const ws = socket = new WebSocket(url, { handshakeTimeout: 15000 });
    let heartbeat, deadline, latest = 0;
    ws.on('unexpected-response', (_req, res) => {
      if ([401, 403].includes(res.statusCode)) stopped = true;
      log(`Handshake HTTP ${res.statusCode}`);
      res.resume();
      ws.terminate();
    });
    ws.on('open', () => {
      attempts = 0;
      log('Connected; subscribing to EURUSD');
      ws.send(JSON.stringify({ cmd: 'subscribe', data: {
        channel: 'ticker', symbols: ['EURUSD'] } }));
      heartbeat = setInterval(() => {
        if (ws.readyState !== WebSocket.OPEN || deadline) return;
        ws.send(JSON.stringify({ cmd: 'ping' }));
        deadline = setTimeout(() => ws.terminate(), options.timeout || 20000);
      }, options.heartbeat || 60000);
    });
    ws.on('message', raw => {
      let packet;
      try { packet = JSON.parse(raw.toString()); }
      catch { log('Skipped malformed JSON'); return; }
      for (const item of Array.isArray(packet) ? packet : [packet]) {
        if (!item || typeof item !== 'object') continue;
        if (item.cmd === 'pong') {
          clearTimeout(deadline); deadline = undefined;
        } else if (item.cmd === 'error') {
          log(`Server error code ${String(item.code)}`);
          // Stop for inspection; do not retry a rejected subscription blindly.
          stopped = true; ws.terminate(); return;
        } else if (item.cmd === 'ticker') {
          const row = normalize(item.data);
          if (!row) { log('Skipped invalid or unexpected quote'); continue; }
          if (row.event_ms < latest) { log('Skipped older quote'); continue; }
          latest = row.event_ms;
          emit(row);
        }
      }
    });
    // Avoid printing error messages that might contain the credential URL.
    ws.on('error', () => log('WebSocket transport error'));
    ws.on('close', () => {
      clearInterval(heartbeat); clearTimeout(deadline);
      log('Disconnected; mark cached quotes stale');
      if (!stopped) retry = setTimeout(connect, delay(++attempts));
    });
  }
  connect();
  return () => { stopped = true; clearTimeout(retry); socket?.terminate(); };
}

if (require.main === module) {
  const key = process.env.TICKDB_API_KEY;
  if (!key) { console.error('Set TICKDB_API_KEY first'); process.exit(1); }
  const stop = start(key);
  process.once('SIGINT', stop);
  process.once('SIGTERM', stop);
}
module.exports = { normalize, start };
Enter fullscreen mode Exit fullscreen mode

Set the key in your terminal environment or secret manager, then run the collector. The following commands use POSIX shell syntax; replace the placeholder locally and do not commit the key:

export TICKDB_API_KEY='YOUR_API_KEY'
node forex_ws.cjs
Enter fullscreen mode Exit fullscreen mode

For a file sink, run node forex_ws.cjs > quotes.jsonl. Quote records go to standard output; connection diagnostics go to standard error. Use Ctrl+C to stop the collector and cancel future retries. This is a low-volume tutorial sink, not a durable ingestion service: before expanding the subscription list, add a bounded queue and handle storage backpressure.

The source includes optional parameters so a local simulation can supply a loopback endpoint and shorter timers. Normal command-line execution always uses the documented endpoint. The code and recovery paths were checked with constructed messages and a local WebSocket server; a credentialed live connection was not tested for this article.

3. Interpret the output without inventing price semantics

The parser requires the requested symbol, the forex product type, finite positive prices, an ask at least as high as the bid, and a valid positive integer timestamp in milliseconds. Missing or empty price fields are rejected instead of turning into zero. These are application validation rules; a rejected quote should be counted and investigated if it persists.

For a constructed input with bid 1.10000 and ask 1.10020, the midpoint is 1.10010 and the spread is 0.00020 in price units. With the conventional EUR/USD pip size of 0.0001, that spread is two pips. The program leaves the result in price units so the same label is not accidentally applied to a pair with a different pip convention. JavaScript floating-point subtraction may print extra decimal digits; format at the display boundary, and use decimal arithmetic for calculations that require exact decimal values.

event_ms preserves the provider's timestamp. received_ms records the collector's local wall-clock time. Their difference is a useful diagnostic only after checking clock synchronization and timestamp semantics; it is not a measurement of pure network latency. Store the raw timestamp before formatting it into a local display timezone.

Within each connection, the example skips messages older than the last accepted timestamp. It allows equal timestamps because a millisecond timestamp is not a unique event ID. This provides a simple latest-quote policy, not complete deduplication. The timestamp watermark resets on reconnect; applications that require ordering across sessions need a separate persistence and reconciliation policy.

4. Recover the connection and expose stale data

Every successful reconnect sends the subscription again. The sample follows the documented retry stages: one second for the first three unsuccessful reconnect attempts, five seconds for the next three, and thirty seconds thereafter. A successful connection resets the counter. For a deployed service, also monitor repeated short-lived connections so an open-close loop does not go unnoticed.

The heartbeat sends the JSON command ping every sixty seconds and terminates the connection if its corresponding JSON pong does not arrive within twenty seconds. These are configurable application checks. WebSocket protocol Ping/Pong frames are handled separately by the library; receiving one does not make an old EUR/USD quote fresh.

Figure 2. Recovery resumes future updates. Keep cached prices marked stale until your application accepts a sufficiently fresh quote; the gap is not automatically backfilled.

A dashboard should therefore maintain two indicators: whether the connection is healthy, and when the last usable quote arrived. This collector prints connection changes and quote timestamps; the consuming application must implement the visible stale state. On a disconnect, retain the last price only with a stale label. After reconnecting, evaluate the new quote's age before marking it current.

Choose the freshness threshold for the pair, session, and application. A quiet market or a weekend can produce no new quotes while the heartbeat still succeeds. Reconnecting repeatedly because no price changed will not create new market activity.

The example deliberately stops on any server error message. Read the reported code and consult the error documentation before restarting. TickDB documents handshake HTTP 401/403 for authentication or access failures and code 4005 for a channel permission failure. Repeating the same rejected subscription cannot repair those conditions. A production client can classify transient errors for bounded retries, but should keep permission and configuration errors out of that loop.

5. Verify behavior before connecting a dashboard

Run the first live check during an active forex session. Look for the requested pair, sensible bid/ask values, timestamps in the expected unit, and continuing valid records. If the socket opens but no quotes arrive, inspect subscription feedback, account access, symbol format, and the session before changing the parser.

Then interrupt the network briefly. The collector should report disconnection, reconnect, and send the same subscription again. Test an unavailable or invalid key separately: it should stop on HTTP 401/403 rather than repeatedly attempting authentication. In a local simulation, send malformed JSON, missing prices, an older timestamp, and no heartbeat response; check each handling path without depending on live market activity.

A current quote after reconnect is evidence that collection resumed. It says nothing about the quotes that occurred during the outage. If your application needs continuous bars or a tick archive, define what historical data can actually be retrieved, reconcile the missing interval, and leave unrecoverable gaps explicitly marked. For a live display, the practical completion criterion is narrower: the requested quote stream resumes, stale state is visible, and no missing interval is presented as uninterrupted coverage.

Source notes: TickDB Ticker, WebSocket Best Practices, Ping, and Error Codes; Tiingo Real-time Forex WebSocket API Documentation; Massive Forex WebSocket Overview; and the ws project documentation. Checked October 25, 2026. Examples illustrate integration behavior, not execution quality or provider performance.

Top comments (0)