Short answer: make the clock, batch boundary, and reconnect sequence explicit in your test harness; never wait for wall-clock milliseconds to guess when a stock watchlist update should arrive.
A watchlist is a trust surface. A trader sees AAPL at 10:01:02, then a price event for 10:01:01 appears after reconnect, and the UI looks broken even if every packet was delivered. The useful contract is simpler: each event has a sequence, a market timestamp, and an ordering scope; a batch is just a transport optimization around that contract.
Ship it.
This article uses a small quote stream as the example. The same method works for a browser data channel, a WebSocket, or a server-sent stream. The transport is replaceable. The assertions are not.
Start with a ledger, not a timer
Store one append-only ledger per watchlist (or per account if several lists share a feed). An entry needs symbol, sequence, observedAt, and a kind. observedAt is data supplied by the feed, not the test runner's current time. A client may receive five quote events in one network batch; it should apply them in sequence order and remember the last committed sequence.
The server-side test fixture can model a market burst as data:
type QuoteEvent = {
symbol: string;
sequence: number;
observedAt: number;
priceCents: number;
kind: "quote" | "halt";
};
type Envelope = {
watchlistId: string;
events: QuoteEvent[];
firstSequence: number;
lastSequence: number;
};
const burst: Envelope = {
watchlistId: "wl-42", firstSequence: 810, lastSequence: 814,
events: [
{ symbol: "AAPL", sequence: 810, observedAt: 1710000000000, priceCents: 18420, kind: "quote" },
{ symbol: "MSFT", sequence: 811, observedAt: 1710000000040, priceCents: 41710, kind: "quote" },
{ symbol: "AAPL", sequence: 812, observedAt: 1710000000060, priceCents: 18428, kind: "quote" },
{ symbol: "NVDA", sequence: 813, observedAt: 1710000000100, priceCents: 90230, kind: "quote" },
{ symbol: "MSFT", sequence: 814, observedAt: 1710000000120, priceCents: 41702, kind: "quote" }
]
};
Notice what is absent: a promise that each event gets its own callback, or that a callback occurs within 100 milliseconds. Those are implementation details. The ledger gives the test something durable to check.
How should realtime batched event delivery tests model reconnects and flaky timing?
Use a virtual scheduler with named actions. At time 0, connect and deliver sequences 810-812. At time 1,200, close the session. At time 1,250, ask for events after sequence 812. At time 1,300, deliver a batch containing 813-814. The numbers are labels in the scenario, not a sleep instruction.
type Action =
| { at: number; type: "connect" }
| { at: number; type: "close" }
| { at: number; type: "deliver"; envelope: Envelope };
class VirtualScheduler {
private nowMs = 0;
private queue: Action[] = [];
schedule(action: Action): void { this.queue.push(action); this.queue.sort((a, b) => a.at - b.at); }
runUntil(limit: number, run: (action: Action, now: number) => void): void {
while (this.queue[0]?.at <= limit) { const action = this.queue.shift()!; this.nowMs = action.at; run(action, this.nowMs); }
this.nowMs = limit;
}
}
class WatchlistModel {
private lastSequence = 0;
private prices = new Map<string, number>();
apply(envelope: Envelope): void {
if (envelope.firstSequence > this.lastSequence + 1) throw new Error(`gap:${this.lastSequence + 1}-${envelope.firstSequence - 1}`);
for (const event of envelope.events) { if (event.sequence <= this.lastSequence) continue; this.prices.set(event.symbol, event.priceCents); this.lastSequence = event.sequence; }
}
cursor(): number { return this.lastSequence; }
}
A test now drives actions and inspects state. It can run 10,000 schedules in a fraction of a second because there is no real clock. More importantly, a failure points to a missing sequence or an invalid replay, not to a machine that happened to be busy.
I keep two clocks in the design. The virtual clock orders transport actions; observedAt remains market data. Mixing them is a subtle source of false failures: a quote can have an older exchange timestamp and still be the next valid sequence.
Batch boundaries are a contract you can vary
A good harness generates equivalent deliveries with different packet shapes. Feed the model one event at a time, then all five events, then two batches split at every possible boundary. The final ledger and cursor should match. This catches code that accidentally treats “one message” as “one quote.”
Test duplicate delivery too. Reconnect systems often replay the last acknowledged item so the client can close a race. Idempotence means sequence 812 delivered twice leaves the same price and cursor as sequence 812 delivered once. A duplicate must not trigger a second notification, analytics charge, or portfolio calculation.
Then test a gap. If the client receives 810 and 812, it should enter a recovery state and request the missing range; silently applying 812 damages trust. Your recovery API can be abstract in unit tests: a function that returns events after a cursor. Integration tests can bind that function to the chosen transport.
Transport details still matter
WebRTC data channels expose ordered and unordered delivery choices, and reliable or partially reliable modes. Those choices are useful only after the application contract is clear. An unordered channel can reduce waiting for an unrelated packet, but it requires the ledger and sequence checks shown above. A reliable channel does not remove the need for deduplication after reconnect. The W3C WebRTC Recommendation describes these data-channel properties; use it as the protocol reference rather than relying on browser folklore.
For a watchlist, keep authentication and authorization outside the event payload. The server should bind a connection to an account and verify that the requested symbols belong to that account. The client may render a stale row during recovery, but it should label freshness from the cursor state instead of inventing a price.
The catch is operational complexity. Virtual-time unit tests cannot prove TLS setup, browser permission behavior, NAT traversal, proxy buffering, or a provider's idle timeout. Add a small integration suite with a real browser and a controllable test endpoint. Keep those tests focused on connection establishment, one reconnect, and one batch; leave combinatorial ordering to the deterministic harness.
Choose the simplest transport that meets your latency and connectivity needs, then make the event ledger transport-independent. If clients are on unpredictable networks or need peer-to-peer paths, a WebRTC data channel may fit. If the system is server-to-browser with ordinary HTTP reachability, a WebSocket or server-sent stream can be easier to operate. Your mileage may vary with corporate proxies and mobile sleep behavior.
Before shipping, I want four signals in logs: connection identifier, watchlist identifier, previous cursor, and delivered range. Metrics should count gap recoveries and duplicate drops, not just messages per second. A test that asserts those counters against a scripted schedule gives you an early warning when a batching optimization changes semantics.
Keep the final checklist in prose: define the cursor, generate equivalent chunkings, script reconnects with virtual time, assert idempotence, inject a gap, and reserve a few end-to-end checks for the real network. That sequence is short enough for a solo team to maintain and strict enough to protect a trader's view.
References
- https://www.w3.org/TR/webrtc/
- https://www.rfc-editor.org/rfc/rfc6455
- https://html.spec.whatwg.org/multipage/web-sockets.html
Top comments (0)