Blog
WebSocket performanceJavaScriptMarket data

Batching WebSocket UI Updates

Batching price updates with requestAnimationFrame: a runnable JavaScript demo and measured rendering costs at 150 and 1,500 messages per second.

Siebly.io12 min readMarkdown

A WebSocket feed can send several price updates between browser paints. If the message handler rebuilds a price table each time, some of those table states are replaced before they can appear on screen. The application has done the DOM work without producing another visible update.

Batching moves that rebuild into a requestAnimationFrame callback. Each message updates the stored price, and the callback renders the latest values together before a repaint.

The example below compares both approaches on a table of 100 synthetic symbols. Both modes parse the same messages and use the same renderer. Changing when that renderer runs makes it possible to measure how much repeated DOM work batching removes.

Batching Price Updates

A price might change from 100.01 to 100.02 to 100.03 before the browser's next paint. A label showing the latest price only needs to display 100.03. Storing one value per symbol lets each new price replace the previous one while the table waits to render.

The first message schedules an animation-frame callback. Later messages update the stored prices but leave that callback in place. When it runs, it clears the pending request and renders the current values. Another message can then schedule the next update. The demo's scheduleRender() function implements this with one request ID and null when no request is pending.

requestAnimationFrame generally follows the display's refresh rate. That means a 120 Hz display can run this renderer more often than a 60 Hz display. Most browsers also pause the callbacks in hidden tabs. The null check avoids relying on a request ID being nonzero.

A watchlist may need only ten visible updates per second. A timer that reads the latest prices every 100 ms can reduce rendering further. It still needs to display the last value after a burst ends: a throttle that renders only at the start of each interval can leave an older price on screen.

Handling Deltas and Events

Replacing an earlier price works because the new message contains the complete value needed by that label. An order-book delta describes a change to existing state. Skipping a required delta can leave the book wrong even if later messages arrive normally. The book has to apply the required updates in order, following the exchange's sequence rules, before its display can use the result.

The same distinction applies to trades used for volume or candles: each required trade contributes to the calculation, even when the display skips intermediate totals. Fills and order events also have to reach the account workflow independently of rendering. If those events are missing, recovery has to fill the gap.

Running the Demo

Save the complete block below as websocket-ui-demo.html and open it in a desktop browser. Select a rate and mode, then press Run six seconds. Keep the tab visible and repeat with the other mode. If the browser blocks workers for local files, serve the containing directory with python3 -m http.server 8080 and open http://localhost:8080/websocket-ui-demo.html.

A worker generates one JSON string per update across 100 fixed symbols. Every four milliseconds, its timer checks how many updates are due and sends them, so some arrive in small bursts. Each run sends exactly rate × seconds messages. The worker takes the place of a WebSocket feed; the demo needs no exchange connection or credentials, and network timing doesn't affect the comparison.

Both modes rebuild the full table with the same function. That is deliberately simple: it makes the cost of repeated table replacement easy to compare. An implementation that updates only changed text nodes would do less work per render and could show a different saving.

websocket-ui-demo.html

html
<!doctype html>
<html lang="en">
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Price-board batching comparison</title>
  <style>
    body {
      font: 16px system-ui;
      max-width: 46rem;
      margin: 2rem auto;
      padding: 0 1rem;
    }
    label,
    button {
      display: inline-block;
      margin: 0 1rem 1rem 0;
    }
    select,
    button {
      font: inherit;
    }
    table {
      border-collapse: collapse;
      width: 100%;
    }
    th,
    td {
      padding: 0.2rem 1rem;
      border-bottom: 1px solid #888;
      text-align: right;
    }
    th:first-child,
    td:first-child {
      text-align: left;
    }
    pre {
      white-space: pre-wrap;
      overflow-wrap: anywhere;
    }
  </style>
  <h1>Price-board batching comparison</h1>
  <p>Keep this tab visible. Each run uses 100 synthetic symbols.</p>
  <label
    >Mode
    <select id="mode">
      <option value="immediate">Render every message</option>
      <option value="batched">Render once per frame</option>
    </select>
  </label>
  <label
    >Messages per second
    <select id="rate">
      <option>150</option>
      <option>1500</option>
    </select>
  </label>
  <button id="run">Run six seconds</button>
  <pre id="result" role="status">Ready.</pre>
  <table>
    <thead>
      <tr>
        <th scope="col">Symbol</th>
        <th scope="col">Price</th>
      </tr>
    </thead>
    <tbody id="prices"></tbody>
  </table>
  <script>
    const symbols = Array.from({ length: 100 }, (_, i) => `PAIR${i}`);
    const body = document.querySelector('#prices');
    const output = document.querySelector('#result');
    const button = document.querySelector('#run');
    let running = false;

    function produce() {
      self.onmessage = ({ data: { rate, seconds, symbols } }) => {
        const total = rate * seconds;
        const started = performance.now();
        let sequence = 0;
        const timer = setInterval(() => {
          const due = Math.min(total, Math.floor(((performance.now() - started) * rate) / 1000));
          while (sequence < due) {
            sequence += 1;
            self.postMessage(
              JSON.stringify({
                symbol: symbols[(sequence - 1) % symbols.length],
                price: 100 + (sequence % 10000) / 100,
                sequence,
              }),
            );
          }
          if (sequence === total) {
            clearInterval(timer);
            self.postMessage({ done: true });
          }
        }, 4);
      };
    }

    window.runBenchmark = ({ mode, rate, seconds = 6 }) =>
      new Promise((resolve, reject) => {
        if (
          running ||
          document.hidden ||
          !['immediate', 'batched'].includes(mode) ||
          ![150, 1500].includes(rate) ||
          !Number.isInteger(seconds) ||
          seconds < 1 ||
          seconds > 30
        ) {
          reject(new Error('Use an idle, visible tab and valid run settings.'));
          return;
        }
        running = true;
        button.disabled = true;
        output.textContent = 'Running...';
        const latest = new Map(symbols.map((symbol) => [symbol, 100]));
        let renders = 0;
        let renderMs = 0;
        let received = 0;
        let frame = null;
        let probe = null;
        let lastFrame = null;
        let finished = false;
        const intervals = [];
        const started = performance.now();
        const url = URL.createObjectURL(
          new Blob([`(${produce.toString()})()`], { type: 'text/javascript' }),
        );
        const worker = new Worker(url);

        function render() {
          const start = performance.now();
          // Symbols are fixed local identifiers; prices are finite numbers.
          body.innerHTML = symbols
            .map((symbol) => `<tr><td>${symbol}</td><td>${latest.get(symbol).toFixed(2)}</td></tr>`)
            .join('');
          renderMs += performance.now() - start;
          renders += 1;
        }

        function scheduleRender() {
          if (frame !== null) return;
          frame = requestAnimationFrame(() => {
            frame = null;
            render();
          });
        }

        function measureFrames() {
          const now = performance.now();
          if (lastFrame !== null) intervals.push(now - lastFrame);
          lastFrame = now;
          probe = requestAnimationFrame(measureFrames);
        }

        function cleanup() {
          finished = true;
          worker.terminate();
          URL.revokeObjectURL(url);
          cancelAnimationFrame(frame);
          cancelAnimationFrame(probe);
          clearTimeout(timeout);
          document.removeEventListener('visibilitychange', onVisibility);
          running = false;
          button.disabled = false;
        }

        function fail(message) {
          if (finished) return;
          cleanup();
          output.textContent = message;
          reject(new Error(message));
        }

        function onVisibility() {
          if (document.hidden) fail('Run cancelled because the tab became hidden.');
        }

        function finish() {
          if (finished) return;
          const elapsedMs = performance.now() - started;
          cleanup();
          const sorted = [...intervals].sort((a, b) => a - b);
          const result = {
            mode,
            rate,
            seconds,
            received,
            renders,
            renderMs,
            elapsedMs,
            frameSamples: sorted.length,
            frameGapP95Ms: sorted[Math.ceil(sorted.length * 0.95) - 1] ?? null,
            frameGapMaxMs: sorted.at(-1) ?? null,
            lastSequence: received,
            finalPrices: Object.fromEntries(latest),
          };
          const { finalPrices, ...display } = result;
          output.textContent = JSON.stringify(display, null, 2);
          resolve(result);
        }

        const timeout = setTimeout(() => fail('Run exceeded 120 seconds.'), 120000);
        document.addEventListener('visibilitychange', onVisibility);
        worker.onerror = () => fail('Worker failed. Try serving this file over localhost.');
        worker.onmessage = ({ data }) => {
          if (finished) return;
          if (typeof data === 'object' && data.done) {
            // Earlier frame callbacks flush the final state before finish runs.
            requestAnimationFrame(finish);
            return;
          }
          const update = JSON.parse(data);
          if (
            !latest.has(update.symbol) ||
            !Number.isFinite(update.price) ||
            update.sequence !== received + 1
          ) {
            fail('Unexpected update or sequence gap.');
            return;
          }
          received += 1;
          latest.set(update.symbol, update.price);
          if (mode === 'immediate') render();
          else scheduleRender();
        };
        probe = requestAnimationFrame(measureFrames);
        worker.postMessage({ rate, seconds, symbols });
      });

    button.onclick = () =>
      window
        .runBenchmark({
          mode: document.querySelector('#mode').value,
          rate: Number(document.querySelector('#rate').value),
        })
        .catch((error) => {
          output.textContent = error.message;
        });
  </script>
</html>

At 150 messages per second, received should be 900; at 1,500, it should be 9000. Both modes parse every message and store every price. In batched mode, several of those updates can reach the model before the table is rebuilt. The returned result includes finalPrices for automated checks, while the visible JSON leaves out that 100-entry map.

The symbol list keeps the model at 100 entries. The frame-interval array grows only for the duration of a benchmark run. Keeping every timing sample in a long-running application would let that array grow indefinitely, so ongoing telemetry needs a storage limit.

Benchmark Results

The measurements were recorded on 31 October 2026 using this exact example on an Apple M4 Mac with 10 CPU cores and 32 GiB of RAM. The environment was macOS (Darwin 24.6.0) with Playwright's headless Chromium 147.0.7727.15. The viewport was 1440 × 900, with no CPU throttling. Animation-frame callbacks ran at roughly 60 Hz.

Each mode had a two-second warm-up at 150 messages per second, excluded from the results. Each rate then had three six-second trials per mode, alternating which mode ran first. Each table entry is the median across those three trials. The frame column is the median of the three per-run 95th percentiles.

Messages/sModeMessages receivedRender callsTime inside rendererFrame interval p95
150Every message900900219.8 ms17.6 ms
150Once per frame900360135.5 ms17.6 ms
1,500Every message9,0009,0001,159.7 ms17.5 ms
1,500Once per frame9,000361131.8 ms17.6 ms

At 150 messages per second, batching cut render calls by 60% and time inside the renderer by about 38%. At 1,500 messages per second, the reductions were about 96% and 89%. All 12 measured runs consumed the expected number of messages and finished with the expected price for every symbol, both in the model and in the DOM.

The frame intervals barely changed. The renderer did substantially less work, but the test didn't demonstrate a freeze or measure an improvement in input latency. A browser can combine layout and paint work across several DOM changes, so reducing render calls needn't produce an equivalent change in frame timing.

Measurement Limits

renderMs adds up the time spent inside render(), measured with performance.now(). It includes formatting strings and replacing DOM nodes. Worker generation, JSON parsing, and later style, layout, paint, and compositing work are outside that measurement, so it doesn't represent total CPU time.

frameGapP95Ms measures the 95th percentile of the intervals between animation-frame probe callbacks. Longer intervals can reveal scheduling delays. They don't count frames actually presented on screen, and the test doesn't include a user interaction. A claim about typing, clicking, or scrolling would need a separate measurement of that interaction.

The results describe this renderer on a desktop browser with synthetic messages. Payload size, rendering code, display refresh rate, and device performance all affect the cost. A message rate on its own doesn't establish that a UI will become unresponsive; these runs don't measure mobile performance or network behaviour.

The repository's scripts/benchmarks/websocket-ui-batching.mjs runner extracts the HTML directly from this article, checks message counts and final prices in the model and DOM, and records every trial. The original results are stored in docs/benchmarks/websocket-ui-batching-2026-10-31.json. The same protocol can be repeated from a website checkout:

Repeat the benchmark

sh
npm ci
npx playwright install chromium
node scripts/benchmarks/websocket-ui-batching.mjs

New runs write to tmp/websocket-ui-performance/benchmark.json, leaving the dated record intact. The HTML file's mode selector also supports manual comparisons. Repeating the trials with alternating mode order helps account for run-to-run variation; message counts and final values need to agree before comparing timings.

Connecting a WebSocket

In an application, the WebSocket message handler replaces the worker's handler. It decodes and validates the payload, applies the update to the model, and calls scheduleRender(). The decoder has to match the exchange's payload and sequence rules. The demo's symbol, price, and sequence fields belong to its synthetic feed.

A Node.js service using Siebly exchange SDKs can supply the browser feed while keeping exchange credentials on the server. By collecting the latest prices for subscribed symbols, it can send snapshots at the display's required rate. That reduces incoming browser messages as well as table rebuilds.

The fixed symbol list in the demo keeps storage bounded. A real watchlist can change, so unsubscribing also needs to remove that symbol's state. Recording when each price arrived lets the display mark it stale after its allowed age, even if it remains the most recent value received.

Hidden tabs need similar care. The demo cancels a run when the tab becomes hidden because paused animation frames would change the comparison. An application needs to render the latest valid state when the tab becomes visible again and recover any missing data before resuming calculations that depend on it.

Handling a Slow Consumer

A batched renderer still leaves a message handler running for every incoming update. If parsing and state updates take longer than the arrival interval, messages can accumulate before that handler runs. The standard browser WebSocket API provides no way to apply backpressure to incoming messages.

bufferedAmount doesn't expose that backlog. It counts outgoing bytes queued by send(), so checking it can't establish whether received messages are waiting for JavaScript.

Measuring the handler separately from the renderer shows where the time is going. If parsing or aggregation dominates, the socket and those operations can move into a Web Worker. The worker can then send the main thread a display snapshot at the rate the UI needs. Forwarding every decoded event would leave much of the message-handling work on the main thread. Worker messaging and copying also cost time, so that version needs measurement with representative payloads.

If the consumer still falls behind, fewer subscriptions or a lower-rate channel can reduce the incoming work where the service supports them. A queue of required events also needs a size limit and a recovery action when it fills. Discarding arbitrary order-book deltas or fills would leave the underlying state incomplete, even if the chart continued updating.

Related articles

Continue from here

Related Siebly resources

All articles

Subscribe on Substack

Complete the Substack form below to join our newsletter. Substack handles all subscriber data directly.