EUR/USD 1.15365 -0.1% GBP/USD 1.34756 -0.18% USD/JPY 155.020 +0.39% USD/CHF 0.81693 -0.04% AUD/USD 0.71240 -0.18% USD/CAD 1.38986 -0.01% NZD/USD 0.57638 -0.22% BTC/USD 76,670.90 -1.91% ETH/USD 2,467.76 -1.87% · EUR/USD 1.15365 -0.1% GBP/USD 1.34756 -0.18% USD/JPY 155.020 +0.39% USD/CHF 0.81693 -0.04% AUD/USD 0.71240 -0.18% USD/CAD 1.38986 -0.01% NZD/USD 0.57638 -0.22% BTC/USD 76,670.90 -1.91% ETH/USD 2,467.76 -1.87% ·
8 min read exchange rates api node.js

Exchange Rates API in Node.js: Polling vs WebSocket Streaming

Build a fetch poller with backoff in Node.js, then a forex WebSocket client for live tickers and dashboards.

Exchange Rates API in Node.js: Polling vs WebSocket Streaming

Most Node.js integrations for foreign exchange start as a single fetch. A product owner wants a conversion widget. Finance wants a snapshot for invoicing. Engineering ships a timer that hits an exchange rates API every few minutes. That pattern is simple, easy to test, and correct for many workloads. It becomes the wrong tool when a dashboard must track the same EUR/USD live rate the market is printing, or when a tight poll loop burns quota and still lags the tape.

Choosing between HTTP polling and a forex WebSocket in Node.js is a latency, cost, and failure-mode decision. This tutorial builds a fetch-based poller with exponential backoff, then a streaming client for live tickers, and draws a hard line between jobs that only need a snapshot and jobs that need ticks.

Key takeaways

  • Polling with fetch is the right default for rate widgets, hourly conversion, and accounting batches.
  • Exponential backoff, jitter, and Retry-After keep a Node.js poller from amplifying 429s and brief outages.
  • A forex WebSocket pays off when many pairs must update in sub-second time for a live ticker or alert loop.
  • Streaming clients still need heartbeat, subscribe-on-open, and reconnect with backoff.
  • Confirm symbols on the full live rates board before hard-coding pairs.

When polling an exchange rates API is enough

Polling is a scheduled GET against a REST exchange rates API. The Node.js process owns the clock: it decides how stale a rate may be, caches the last successful payload, and converts amounts against a snapshot that carries a timestamp. That model maps cleanly onto widgets, invoices, and batch jobs.

Use HTTP polling when the product can tolerate snapshot freshness rather than every print:

  • A marketing or help-center widget that can lag by 30–60 seconds.
  • Checkout conversion with a short-lived cache of one to five minutes.
  • Invoicing or ERP posts that lock an hourly or end-of-day rate.
  • A warehouse job that writes a compact time series instead of every tick.

Polling is a poor fit when the interface is a moving wall of prices, when an alert must fire on a threshold within about a second, or when the process watches dozens of pairs and would multiply HTTP volume to fake a stream. Polling every few hundred milliseconds is not a live ticker. It is a rate-limit incident waiting to happen.

Lock the symbol list to what the product actually shows. Browse the current board of pairs first. Unused pairs in a poller are wasted quota and extra parsing work.

Fetch-based polling with exponential backoff

A production poller is more than setInterval. Intervals drift under load, overlapping requests run if a slow GET exceeds the period, and a naive retry loop will hammer an endpoint that just returned 429.

The snippet below uses a single-flight loop: one request at a time, a success path that waits the intended interval, and a failure path that doubles delay up to a cap. It honors Retry-After when the server sends it. URL and API key come from the environment so the same code can point at a Live-Rates REST endpoint in staging and production.

const RATES_URL = process.env.RATES_URL;
const API_KEY = process.env.LIVE_RATES_API_KEY;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function jitter(ms) {
  return Math.floor(ms * (0.5 + Math.random()));
}

async function fetchRates() {
  const res = await fetch(RATES_URL, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (res.status === 429) {
    const err = new Error('rate limited');
    err.status = 429;
    err.retryAfter = Number(res.headers.get('retry-after')) || null;
    throw err;
  }
  if (!res.ok) {
    const err = new Error(`HTTP ${res.status}`);
    err.status = res.status;
    throw err;
  }
  return res.json();
}

async function pollRates({ intervalMs = 60_000, maxBackoffMs = 300_000, onTick }) {
  let consecutiveFailures = 0;
  while (true) {
    try {
      const payload = await fetchRates();
      consecutiveFailures = 0;
      onTick(payload);
      await sleep(intervalMs);
    } catch (err) {
      consecutiveFailures += 1;
      const retryAfterMs = err.retryAfter ? err.retryAfter * 1000 : null;
      const backoff = retryAfterMs ?? Math.min(
        maxBackoffMs,
        intervalMs * 2 ** consecutiveFailures
      );
      await sleep(jitter(backoff));
    }
  }
}

pollRates({
  onTick(payload) {
    const eurUsd = payload.rates?.find((r) => r.pair === 'EURUSD');
    if (eurUsd) console.log(eurUsd.rate, eurUsd.timestamp);
  },
});

Do not start a second fetch until the previous one settles. Reset backoff only after a 2xx body parses. Treat 401 and 403 as configuration errors that stop the loop, not as retryable blips. Log HTTP status, pair count, and the payload timestamp so finance can later prove which snapshot a conversion used.

Jitter, Retry-After, and safe caching

Backoff without jitter synchronizes workers. If ten instances fail together, they all retry at 2s, 4s, and 8s and recreate the spike. Multiply the delay by a random factor in the 0.5–1.5 range so recoveries spread out.

Cache the last good document in memory, and optionally in Redis, so a widget never renders a blank rate during a short outage. Pair that cache with an age check: a checkout flow might accept a 60-second-old mid; a compliance export might reject anything older than the invoice timestamp policy.

Persist four fields on every snapshot: the pair in a single canonical form, the mid or bid/ask, the provider timestamp, and local received-at. Converted amounts should cite that snapshot. Hourly conversion jobs should write the rate timestamp next to the money amount so a later audit does not re-convert against a different print.

When a forex WebSocket in Node.js pays off

A forex WebSocket is justified when freshness is the product. Live tickers, dealer boards, treasury dashboards, and threshold alerts all need the next print, not the next poll. One authenticated connection can subscribe to EUR/USD, GBP/USD, and USD/JPY, then fan ticks into an in-process EventEmitter or a browser channel.

Streaming also changes the cost curve. Polling N pairs every second is N times the request volume. A socket carries the same N as messages on one connection. That is why dashboards that look real-time with HTTP become expensive and still look lagged.

Streaming is wasted on a daily FX posting job. Sockets add reconnect logic, heartbeats, and subscribe-on-open. If the user-visible SLA is “within a minute,” poll and keep the process boring.

Streaming ticks into a live dashboard

The Node.js service is usually not the browser. It holds the upstream forex WebSocket, authenticates with a server-side key, and rebroadcasts a thinner tick to internal UIs. The ws package is the usual client. Exact subscribe frames vary by provider; the loop below is the shape every serious client needs: open, subscribe, heartbeat, exponential reconnect, and a clean shutdown.

import WebSocket from 'ws';

const WS_URL = process.env.FOREX_WS_URL;
const API_KEY = process.env.LIVE_RATES_API_KEY;

function jitter(ms) {
  return Math.floor(ms * (0.5 + Math.random()));
}

export function connectForexSocket({ pairs, onTick, onStatus }) {
  let socket;
  let attempt = 0;
  let pingTimer;
  let stopped = false;

  function scheduleReconnect() {
    if (stopped) return;
    const wait = Math.min(30_000, 1000 * 2 ** attempt);
    attempt += 1;
    setTimeout(open, jitter(wait));
  }

  function open() {
    socket = new WebSocket(WS_URL, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });

    socket.on('open', () => {
      attempt = 0;
      onStatus('connected');
      socket.send(JSON.stringify({ op: 'subscribe', pairs }));
      clearInterval(pingTimer);
      pingTimer = setInterval(() => {
        if (socket.readyState === WebSocket.OPEN) socket.ping();
      }, 15_000);
    });

    socket.on('message', (raw) => {
      const msg = JSON.parse(raw.toString());
      if (msg.type === 'tick' || msg.pair) onTick(msg);
    });

    socket.on('close', () => {
      clearInterval(pingTimer);
      onStatus('disconnected');
      scheduleReconnect();
    });

    socket.on('error', () => socket.close());
  }

  open();
  return () => {
    stopped = true;
    clearInterval(pingTimer);
    if (socket) socket.close();
  };
}

On reconnect, resubscribe. Do not assume the server restored the session. If ticks include a sequence number, detect gaps and optionally issue one REST poll to fill. Bound any in-memory queue so a stalled UI cannot grow heap. For a dashboard, coalesce paints; the socket can be faster than the screen.

Polling vs streaming at a glance

Match transport to the freshness the user can actually see. Mixing both in one codebase is normal: poll for invoices, stream for the ticker.

Workload Transport Typical freshness Failure mode to design for
Rate widget HTTP poll 30–60 seconds Stale cache, 429 backoff
Hourly invoicing HTTP poll Hourly snapshot Missing rate timestamp
Checkout conversion HTTP poll + cache 1–5 minutes Serving a blank quote
Multi-pair live ticker WebSocket Each tick Disconnect and resubscribe
Threshold alerts WebSocket Each tick Missed ticks during reconnect

FAQ

Is polling an exchange rates API in Node.js accurate enough for invoices?

Yes, when the invoice stores the rate timestamp alongside the converted amount. Hourly or document-time snapshots are the usual policy. Re-converting later against a live tick will not match the original booking.

How often should a Node.js poller request forex rates?

Widgets commonly poll every 30–60 seconds. Checkout can cache for one to five minutes. Accounting jobs often run hourly or at end of day. Sub-second polling is the wrong substitute for a forex WebSocket.

When should a forex WebSocket replace fetch in Node.js?

Replace polling when the UI is a live ticker, when alerts must fire on the next print, or when many pairs would otherwise multiply HTTP volume. Keep fetch for snapshots, audits, and gap fills after a reconnect.

How should a Node.js client reconnect after a socket drop?

Use exponential backoff with jitter, cap the delay (30 seconds is a common ceiling), resubscribe on every open, and run a heartbeat. Treat auth failures as a stop, not a retry storm.

Can polling and streaming coexist in one application?

Yes. A typical split is a WebSocket for dashboards and alerts, plus a REST poller for invoices, ETL, and filling gaps after a disconnect. Share one canonical pair list across both paths.

Inspect the pair universe on the live rates board, then pick a plan that matches poll volume or streaming needs on the Live-Rates plans page.

Real-time forex rates for your app

Live bid/ask for the pairs you need, updated every second, with a simple JSON & XML API. Try it free for 7 days.