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.
| Surface | Route | Serves |
|---|---|---|
| History | GET /bars | loadBars |
| Symbols | GET /symbols/search, GET /symbols/resolve | searchSymbols, resolveSymbol |
| Snapshots | GET /quotes, GET /depth, GET /tape | The get* reads |
| Stream | WSS /stream | Every subscribe* method |
| Calendars | GET /sessions | resolveSessionInfo, 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
resetrather 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.