Skip to main content

Build the Data Gateway

The gateway is the only backend the chart talks to. It terminates upstream sessions, normalizes their payloads, enforces entitlements, and re-publishes everything over HTTPS and WebSocket. Get its shape right and swapping a vendor later touches one adapter instead of the browser.

Responsibilities

Two charts on the same symbol must not open two upstream subscriptions. Vendor and broker limits are counted upstream — IBKR allows 100 concurrent market data lines regardless of how many browser tabs you have open — so deduplication belongs in the gateway.

Endpoints

A complete gateway is smaller than it looks.

SurfaceRouteServes
HistoryGET /barsloadBars
SymbolsGET /symbols/search, GET /symbols/resolvesearchSymbols, resolveSymbol
SnapshotsGET /quotes, GET /depth, GET /tapeThe get* reads
StreamWSS /streamEvery subscribe* method
CalendarsGET /sessionsresolveSessionInfo, resolveSessionCalendar

One WebSocket carries every subscription type. Opening a socket per subscription exhausts browser connection limits and multiplies upstream reconnect storms.

Stream protocol

Keep the envelope boring and explicit. Every frame names its channel, its subscription, and its sequence.

{ "op": "subscribe", "id": "sub-1", "channel": "bars", "symbol": "AAPL", "interval": "5m" }
{ "op": "data", "id": "sub-1", "seq": 4812, "payload": { "time": 1754040000000, "open": 208.1, "high": 208.4, "low": 208.0, "close": 208.3, "volume": 14200 } }
{ "op": "reset", "id": "sub-1", "reason": "sequence-gap" }

The client side implements MarketDataFeed over that envelope:

import type { MarketDataFeed } from '@tradescript/pro/sdk/core';
import type { Bar } from '@tradescript/pro/sdk/core';

type Frame =
| { op: 'data'; id: string; seq: number; payload: Bar }
| { op: 'reset'; id: string; reason: string };

class StreamClient {
private socket?: WebSocket;
private readonly handlers = new Map<string, (frame: Frame) => void>();
private nextId = 0;

constructor(private readonly url: string) {}

subscribe(request: Record<string, unknown>, onFrame: (frame: Frame) => void): () => void {
const id = `sub-${(this.nextId += 1)}`;
this.handlers.set(id, onFrame);
const socket = this.ensureSocket();
const send = () => socket.send(JSON.stringify({ op: 'subscribe', id, ...request }));
if (socket.readyState === WebSocket.OPEN) send();
else socket.addEventListener('open', send, { once: true });

return () => {
this.handlers.delete(id);
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ op: 'unsubscribe', id }));
}
};
}

private ensureSocket(): WebSocket {
if (this.socket && this.socket.readyState <= WebSocket.OPEN) return this.socket;
const socket = new WebSocket(this.url);
socket.addEventListener('message', (event) => {
const frame = JSON.parse(String(event.data)) as Frame;
this.handlers.get(frame.id)?.(frame);
});
this.socket = socket;
return socket;
}
}

const stream = new StreamClient('wss://gateway.example.com/stream');

export const datafeed: MarketDataFeed = {
onReady: () => ({ supportsRealTime: true }),

async loadBars(symbol, interval, request) {
const query = new URLSearchParams({
symbol: symbol.ticker,
interval,
from: String(request.startTime),
to: String(request.endTime),
});
const response = await fetch(`https://gateway.example.com/bars?${query}`);
if (!response.ok) throw new Error(`History failed: ${response.status}`);
return { bars: (await response.json()) as Bar[] };
},

subscribeRealTimeBars(subscription, callback) {
return stream.subscribe(
{ channel: 'bars', symbol: subscription.symbol.ticker, interval: subscription.interval },
(frame) => {
if (frame.op === 'data') callback(frame.payload);
else subscription.onResetCacheNeeded?.();
},
);
},
};

The reset frame is the important one. When the gateway cannot guarantee continuity, it says so, and the chart reloads authoritative history rather than carrying a corrupted bar forward.

Sequence gaps and resync

Upstream feeds drop. Detect it at the gateway, never in the browser.

For order books, a gap means the book is invalid, not stale. Discard it, request a new snapshot, and buffer incremental updates until one arrives whose sequence covers the snapshot. Publishing a book that silently missed a delete is worse than publishing nothing — the depth ladder and liquidity heatmap will render phantom liquidity for as long as that level survives.

The SDK carries this evidence for you: SdkMarketDepth.quality records delivery mode, observation time, sequence continuity, and resync status per frame. Fill it from what the gateway observed, never from what it assumes.

Conflation and backpressure

A book can update thousands of times per second. A browser cannot paint that, and a slow client must never stall the upstream reader.

  • Coalesce per subscription on a fixed interval — 50–100ms is imperceptible on a chart and cuts frame counts by orders of magnitude.
  • Keep the newest state, not a queue of deltas, when a client falls behind.
  • Bound each client's send buffer; disconnect with a reset rather than growing memory without limit.
  • Never conflate the tape the way you conflate a book. Trade prints are events; dropping one falsifies volume and footprint totals. Batch them instead.

Authentication and entitlements

The chart sends whatever your app sends — a session cookie or a bearer token on the REST calls and the WebSocket upgrade. The gateway resolves that to a user, then enforces:

  • Which symbols and venues the user is licensed to see.
  • Real-time versus delayed versus end-of-day.
  • Display versus non-display usage, which most venues price separately.
  • Per-seat concurrency, since vendor contracts are usually counted per user.

Entitlement failures should surface as a typed error, not as an empty bar array. An empty result means "no data exists"; an error means "you may not have it". The chart renders those two states differently, and hiding the difference makes production issues unfalsifiable.

Caching

Cache history aggressively and stream data not at all.

Closed bars never change except by correction, so a bar older than the current one is safe to serve from cache indefinitely. The developing bar is not. IBKR's pacing rules make caching mandatory rather than an optimization; vendor billing usually does too.

Implement resetCache so chart.resetCache() clears feed-side caches before the SDK reloads:

async resetCache(request) {
await fetch('/api/cache/reset', {
method: 'POST',
body: JSON.stringify({ symbol: request?.symbol?.ticker }),
});
}

Client-side, createCachingDatafeed from @tradescript/pro/sdk already handles bar-store dedup, range merging, and interval upsampling. Wrap your feed with it before writing that logic yourself.

Production checklist

  • One upstream subscription per symbol, regardless of client count.
  • Sequence tracked per subscription; gaps emit reset.
  • Book snapshots buffered and sequence-matched before publication.
  • Conflation on books and quotes; batching, never dropping, on the tape.
  • Entitlement checked per request and per subscription, not only at login.
  • CORS configured for the application origin, including custom auth headers.
  • Reconnect backoff matching each vendor's stated policy.
  • Timestamps converted to Unix milliseconds exactly once, at the boundary.

Next steps

  • FIX protocol — when the upstream is a venue session rather than an HTTP API.
  • Market data vendors — concrete upstreams to put behind the gateway.
  • Deployment — CSP, origins, and the browser-side production checklist.