Dev.to WebDev 🛠 Dev 👁 0 📖 10 min read

Building a Sub-100ms Live Odds Ticker: REST vs WebSocket vs Webhooks Compared

If you've ever built a live odds screen, you know the feeling. The price on your page says 1.91, the bookmaker's site says 1.85, and a user is already screenshotting it for your support inbox. Live odds are one of the l

If you've ever built a live odds screen, you know the feeling. The price on your page says 1.91, the bookmaker's site says 1.85, and a user is already screenshotting it for your support inbox.

Live odds are one of the least forgiving data types you can display. A goal, a red card or a break of serve can move a line within a second. Whether your ticker feels instant or broken depends less on your UI framework and more on how the data reaches your app.

In this guide we'll build a live odds ticker three ways (REST polling, WebSocket and Webhooks) and compare them honestly: latency, cost, complexity and failure modes. Along the way we'll write a reconnect-safe client you can reuse in production.

For the examples I'll use the Orbistats Odds API because it exposes all three delivery methods behind one normalized schema. The patterns apply to any provider, though.

Heads-up: All numbers in the latency tables below are a budget framework, not benchmark results. Run the measurement script in section 6 against your own region and plan before you quote any figure.

Table of contents
What "sub-100ms" actually means
The normalized odds schema we'll consume
Approach 1: REST polling
Approach 2: WebSocket streaming
Approach 3: Webhooks
Measuring your real latency
Head-to-head comparison
The architecture I'd ship
Production checklist
Final thoughts and next steps

  1. What "sub-100ms" actually means

"Sub-100ms" is a vague claim until you say from where to where. A live odds update passes through several hops:

text
Bookmaker price change
↓
Provider ingestion + normalization
↓
Provider delivery (REST / WS / Webhook)
↓
Network travel to YOUR server or browser
↓
Your parsing + business logic
↓
Render on screen

Providers usually quote latency for the second and third hops only. Orbistats, for example, positions its live feed as sub-50ms and has published a piece on what sub-50ms actually requires, end to end. That figure covers their side. Your network distance, TLS handshakes, JSON parsing and rendering come on top.

So when we say sub-100ms, we mean a budget like this:

Stage Example budget
Provider → your edge ~50 ms (provider claim)
Network RTT to your region 10–40 ms
Parse + diff + state update 1–5 ms
Render 8–16 ms (one frame)

The point: delivery method decides whether you even can hit that budget, because polling adds an entirely separate cost called staleness.

  1. The normalized odds schema we'll consume

One of the hardest parts of any odds integration is that every bookmaker formats prices differently. A normalized API removes the need to maintain one parser per bookmaker. Orbistats returns markets in a consistent shape, like this 1X2 example:

json
{
"market": "1X2",
"odds": {
"home": 1.91,
"draw": 3.40,
"away": 4.20
}
}

The same schema family covers moneyline, spreads, handicaps, totals, player props and futures, for both pre-match and live odds, plus line movement. Auth is a standard bearer token against https://api.orbistats.com/v1/:

http
Authorization: Bearer YOUR_API_KEY

You can get a key in a minute from the signup page, and the documentation lists the full resource set (fixtures, results, standings, odds, statistics, lineups, events, teams, players, competitions, countries).

Here is the tiny type we'll use everywhere below:

ts
// types.ts
export interface OddsTick {
matchId: string;
market: string; // e.g. "1X2"
odds: Record;
receivedAt: number; // ms, set by OUR code
}

  1. Approach 1: REST polling

Polling is where everyone starts, and for good reason: it's simple, cacheable and works everywhere.

js
// poll.js (Node 20+, no dependencies)
const BASE = "https://api.orbistats.com/v1";
const KEY = process.env.ORBISTATS_KEY;

let last = new Map();

async function pollOnce(matchId) {
const t0 = performance.now();
// Check the API reference for the exact odds route and params.
const res = await fetch(${BASE}/football/odds?match_id=${matchId}, {
headers: { Authorization: Bearer ${KEY} },
});
if (!res.ok) throw new Error(HTTP ${res.status});
const body = await res.json();
const rtt = performance.now() - t0;

const prev = last.get(matchId);
if (JSON.stringify(prev) !== JSON.stringify(body)) {
last.set(matchId, body);
console.log(CHANGED (rtt ${rtt.toFixed(0)}ms), body);
}
}

setInterval(() => pollOnce("match_50231").catch(console.error), 1000);

Use the API reference for exact route names. The shape above is what matters.

The hidden cost: staleness

Polling latency isn't just request time. If you poll every T milliseconds, a price change lands at a random moment inside the interval:

Average added delay: T / 2
Worst case: T
Poll interval Avg staleness Worst case Requests/day (1 match, 24h)
5 s 2.5 s 5 s 17,280
1 s 500 ms 1 s 86,400
250 ms 125 ms 250 ms 345,600

Two things jump out. You cannot reach sub-100ms by polling without hammering the API at 10+ requests per second per match. And request volume explodes. On a free tier capped at 150 requests per day (check the pricing page for current limits), a 1-second poll burns your whole allowance in about two and a half minutes.

Use REST for: fixtures, standings, historical backfills, pre-match odds and anything where seconds don't matter. For live scores specifically, see the Live Scores API.

  1. Approach 2: WebSocket streaming

A WebSocket keeps one persistent connection open and the server pushes every change. No request overhead, no polling interval, no staleness. This is the path to sub-100ms.

text
REST: Client → Request → Server → Response (repeat forever)
WebSocket: Client ⇄ one persistent connection ⇄ stream of updates

Here's a production-shaped client with heartbeats and reconnection:

js
// ws-client.js
import WebSocket from "ws";

const WS_URL = process.env.ORBISTATS_WS_URL; // copy from the WebSocket docs
const KEY = process.env.ORBISTATS_KEY;

let attempt = 0;
const state = new Map(); // matchId:market -> latest odds

function connect() {
const ws = new WebSocket(WS_URL, {
headers: { Authorization: Bearer ${KEY} },
});

let heartbeat;

ws.on("open", () => {
attempt = 0;
console.log("connected");
// Subscribe message shape: confirm in the WebSocket API docs.
ws.send(JSON.stringify({ action: "subscribe", channel: "odds", sport: "football" }));
heartbeat = setInterval(() => ws.ping(), 20_000);
});

ws.on("message", (raw) => {
const receivedAt = Date.now();
const msg = JSON.parse(raw);
const key = ${msg.match_id}:${msg.market};
state.set(key, { ...msg, receivedAt });
render(key, state.get(key));
});

ws.on("close", () => {
clearInterval(heartbeat);
// Exponential backoff with jitter: 0.5s, 1s, 2s ... capped at 15s
const delay = Math.min(15_000, 500 * 2 ** attempt++) * (0.5 + Math.random() / 2);
console.log(closed, retrying in ${Math.round(delay)}ms);
setTimeout(connect, delay);
});

ws.on("error", (e) => console.error("ws error", e.message));
}

function render(key, tick) {
console.log(key, tick.odds);
}

connect();

The exact endpoint and subscription format live on the WebSocket API page, so treat WS_URL and the subscribe payload above as placeholders.

The gap problem (most tutorials skip this)

When a socket drops, you miss updates. Reconnecting alone leaves your ticker silently wrong. The fix is a simple pattern:

text

  1. Socket reconnects
  2. Immediately fetch a REST snapshot of current state
  3. Replace local state with the snapshot
  4. Resume applying streamed updates js async function healGap(matchId) { const res = await fetch(https://api.orbistats.com/v1/football/odds?match_id=${matchId}, { headers: { Authorization: Bearer ${process.env.ORBISTATS_KEY} }, }); const snapshot = await res.json(); state.set(matchId, snapshot); }

REST for the truth, WebSocket for the delta. This hybrid is the backbone of nearly every serious live-data system.

  1. Approach 3: Webhooks

Webhooks flip the direction: the provider calls you when something happens.

text
Goal scored → Provider detects event → POST to your URL → Your server reacts

They're ideal for reactions, such as sending a push notification, settling a bet, or updating a database row, rather than for painting a pixel-perfect ticker.

js
// webhook-server.js
import express from "express";
import crypto from "node:crypto";

const app = express();

// Keep the raw body: signature checks must run on the exact bytes received.
app.use("/webhook/sports", express.raw({ type: "application/json" }));

const seen = new Set(); // swap for Redis SET with TTL in production

app.post("/webhook/sports", (req, res) => {
// Header name and algorithm: confirm in the Webhooks docs.
const signature = req.header("x-signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body)
.digest("hex");

const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.sendStatus(401);

const event = JSON.parse(req.body);

// Idempotency: providers may retry, so never process the same event twice.
if (seen.has(event.id)) return res.sendStatus(200);
seen.add(event.id);

res.sendStatus(200); // ACK fast...
queueMicrotask(() => handle(event)); // ...do the heavy work after
});

function handle(event) {
console.log("event:", event.type, event);
}

app.listen(3000);

Details of payloads, retries and signing are on the Webhooks API page. Webhooks are listed alongside historical data and widgets in the Growth plan positioning, so check the pricing page for what each tier includes.

The three webhook rules:

Verify the signature. Anyone can POST to a public URL.
Be idempotent. Retries mean duplicates.
Acknowledge fast. Return 200 immediately and process asynchronously, or the provider will time out and retry.

  1. Measuring your real latency

Don't trust anyone's numbers, including mine. Measure. Here's a script that records the delay between the moment an update is received and its provider timestamp (if one is included), plus a raw RTT probe:

js
// measure.js
import { performance } from "node:perf_hooks";

const KEY = process.env.ORBISTATS_KEY;

async function rttProbe(n = 50) {
const samples = [];
for (let i = 0; i < n; i++) {
const t0 = performance.now();
await fetch("https://api.orbistats.com/v1/", {
headers: { Authorization: Bearer ${KEY} },
});
samples.push(performance.now() - t0);
await new Promise((r) => setTimeout(r, 200));
}
samples.sort((a, b) => a - b);
const p = (q) => samples[Math.floor(q * (samples.length - 1))].toFixed(1);
console.log({ p50: p(0.5), p95: p(0.95), p99: p(0.99), min: p(0), max: p(1) });
}

rttProbe();

For streamed updates, compute Date.now() - msg.timestamp only if your server clock is NTP-synced; otherwise clock skew will fool you. Always report p50, p95 and p99, because averages hide the tail, and the tail is what users notice.

The Status page is also worth bookmarking so you can separate "my code is slow" from "an incident is ongoing".

  1. Head-to-head comparison REST polling WebSocket Webhooks Direction You pull Provider pushes over open socket Provider pushes to your URL Typical added delay T/2 avg (interval-bound) Near zero (network only) Near zero (network + your server) Sub-100ms feasible ❌ not without extreme polling ✅ yes ⚠️ to your server yes, to browsers needs fan-out Request cost High, grows with matches One connection One POST per event Browser-direct ✅ ✅ (but exposes your key) ❌ needs a public server Failure mode Stale data Silent gaps on drop Duplicates and retries Complexity Low Medium Medium Best for Fixtures, standings, history Live odds, line movement Alerts, settlement, DB sync
  2. The architecture I'd ship

Never put your provider key in the browser, and don't open one upstream socket per user. Open one upstream connection and fan out:

text
Orbistats WebSocket ──► Your ingest service
│
(normalize + diff + dedupe)
│
Redis pub/sub
┌─────────┴─────────┐
▼ ▼
WebSocket/SSE gateway Webhook-style jobs
│ (alerts, DB writes)
▼
Browser tickers

Key decisions:

Diff before broadcast. Only push a tick if the price actually changed. This cuts browser traffic dramatically on quiet markets.
Coalesce on the client. If 10 updates arrive in one animation frame, render only the last one with requestAnimationFrame.
Snapshot on connect. New browser tabs should receive current state immediately, then deltas.
Flash direction, not just value. Green up and red down arrows are what make a ticker feel alive.

A minimal browser-side coalescer:

js
const latest = new Map();
let scheduled = false;

socket.onmessage = (e) => {
const tick = JSON.parse(e.data);
latest.set(${tick.match_id}:${tick.market}, tick);
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
for (const tick of latest.values()) paint(tick);
latest.clear();
});
}
};

  1. Production checklist Heartbeats (ping/pong) and dead-connection detection Exponential backoff with jitter on reconnect REST snapshot after every reconnect (gap healing) Webhook signature verification and idempotency keys Rate-limit awareness: back off on 429 instead of retrying instantly Odds-format toggle (decimal / fractional / American) done client-side Pin your API version (/v1/) so breaking changes never surprise you Watch the changelog for additive fields Alert on staleness ("no tick in N seconds for a live match"), not only on errors
  2. Final thoughts and next steps

The short version:

Use REST for everything that isn't time-critical, and for healing gaps.
Use WebSocket for the live ticker itself. It's the only approach that gets you into sub-100ms territory.
Use Webhooks for side effects that must happen once per event.
Combine all three. They aren't competitors, they're roles.

If you want to try this today:

Grab a free key at orbistats.com/signup.
Fire your first request from the Sandbox or the Quickstart.
Browse copy-paste snippets in Examples, or grab an SDK.
Need history for backtesting your pricing models? See the Historical Sports Data API.
Don't want to build UI at all? The drop-in widgets embed a live score or odds board with a snippet.

Coverage spans 13 sports, including football, basketball, cricket and tennis. If you're building for trading teams specifically, the Sportsbooks & Trading page covers the use case in more depth.

What's your current live-odds stack: polling, sockets, or a hybrid? Drop it in the comments. I'd love to compare p95 numbers.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.