bo-grid v3.1.0

Realtime

.md

Tick streams

Coalesce a feed's messages per key and apply one bounded batch a frame with createTickStream, so a burst never costs more than the screen can show.

A market feed sends far more messages than a screen can show. Writing each one straight into rows means thousands of updates to paint 60 frames a second, and the tab stalls. createTickStream from bo-grid/realtime sits between the socket and the grid. Pushing a message into it is cheap: no reactive write, no render. Once a frame it hands the grid the latest patch for each key that changed, at most cap of them.

Pick a feed rate below and compare the messages that come in with the patches the grid applies. At 5,000 messages a second over 12 rows, almost every message is merged into a patch still waiting for its frame.

tick-stream.svelte GitHub ↗

Creating a stream

With plain rows, apply each batch through the grid’s patchRows (Live updates explains why it is the fast path):

import { createTickStream } from 'bo-grid/realtime';

const stream = createTickStream<string, Partial<Quote>>({
  apply: (batch) => api.patchRows(batch), // once a frame, at most `cap` patches
});
stream.start();

socket.onmessage = (e) => {
  const q = JSON.parse(e.data);
  stream.push(q.symbol, q); // merges into the symbol's waiting patch
};

With $state rows, index them once and write the batch with applyPatches, which skips fields whose value did not change, so a chatty feed that re-sends the same price neither re-renders nor flashes:

import { applyPatches, createRowIndex, createTickStream } from 'bo-grid/realtime';

const index = createRowIndex(rows, (r) => r.symbol); // rebuild when symbols are added or removed
const stream = createTickStream<string, Partial<Quote>>({
  apply: (batch) => applyPatches(index, batch),
});

The key you push under is the feed’s key. The grid’s flash and selection follow its own row id (getRowId, row.id by default): keep that stable across updates.

Coalescing

For each key, only the latest state is applied. Two patches that arrive for the same key between frames merge field by field when both are plain objects, so partial messages compose: { bid } then { ask } becomes { bid, ask }. Anything else is last-write-wins. For updates that accumulate, pass merge:

const stream = createTickStream<string, { volume: number }>({
  apply: (batch) => api.patchRows(batch),
  merge: (prev, next) => ({ volume: prev.volume + next.volume }), // sum, don't replace
});

Keys drain in the order they first arrived, so a hot symbol cannot starve a quiet one: a key pushed again keeps its place in the queue.

A price board wants the latest state. A trade tape is the opposite case, where every trade must show: append trades to a TradeTape instead (see Depth ladder).

Feed health

  • cap bounds the patches applied in one frame (400 by default). A burst cannot blow the frame budget. A sustained overload shows as a climbing stream.pending instead of dropped frames.
  • pending counts the keys waiting for a frame: your feed-health number.
  • applied counts the patches applied since the stream was created, a throughput counter. A batch whose apply threw is not counted.
  • stop() pauses draining and keeps what waits, so a paused stream resumes with the latest state; stop({ discard: true }) drops it. flushNow() applies everything at once, ignoring the cap: for a tab regaining focus, or a last paint before teardown.

If apply throws, that frame’s batch is lost: it left the buffer before apply ran, and re-queuing it could apply it twice. The frame loop keeps going. Pass onError to handle the error; without it, it is re-thrown asynchronously so it does not vanish.

Scheduling and tests

The stream drains on requestAnimationFrame, falling back to a 16 ms timer where there is none (server rendering, tests). Pass schedule to drive the frames yourself; that is also how the stream is unit-tested without a browser:

let frame: (() => void) | undefined;
const stream = createTickStream({ apply, schedule: (fn) => ((frame = fn), () => (frame = undefined)) });
stream.start();
stream.push('VNM', { price: 68.6 });
frame?.(); // one frame: apply ran with [['VNM', { price: 68.6 }]]

bo-grid/realtime is a separate entry with its own bundle, and none of it depends on the grid: importing it adds nothing to the grid core.

The live blotter below applies 200 fills a frame into 100,000 orders through a stream and patchRows:

LiveBlotter.svelte Open GitHub ↗

Reference

createTickStream<K, P>(options) returns a TickStream<K, P>.

OptionTypeDefaultWhat it does
apply(batch: Array<[K, P]>) => voidrequiredApplies a frame’s patches. Called at most once a frame, never with an empty batch.
capnumber400Most patches applied in one frame.
merge(prev: P, next: P) => Pfield-wise for plain objectsCombines two patches for one key between frames.
schedule(fn) => cancelrequestAnimationFrameThe frame scheduler.
onError(err: unknown) => voidre-thrown asynchronouslyHears an apply that threw.
MemberWhat it is
push(key, patch)Takes one message’s patch.
start()Begins draining every frame. Idempotent.
stop(opts?)Stops draining; { discard: true } drops the waiting patches.
flushNow()Applies everything waiting, ignoring the cap.
pendingKeys waiting for a frame.
appliedPatches applied since creation.
runningWhether it is draining.

Helpers: createRowIndex(rows, getRowId) maps ids to rows; applyPatches(index, batch) writes a batch into indexed rows, skipping unchanged fields, and returns how many found a row. TickBuffer is the coalescing buffer on its own, for a loop you run yourself.