Short answer: use a realtime surface that supports batched delivery, then make token scope, subscription state, and recovery observable in the watchlist test itself. A fake clock and an explicit event ledger remove timing luck; a vendor choice cannot.
The bill is mostly retention, not the handful of poll updates. Keeping every quote, presence transition, and reconnect payload forever makes the storage line item grow with the session history. Keep a compact audit record for the decision, retain raw delivery fixtures only for the test window, and measure how much replay data you actually need before choosing a backend.
Measure it.
That change is deliberately boring. The expensive term is what you keep, so changing the retention policy moves the bill more reliably than shaving milliseconds from a publish call. The catch is that deleting raw batches removes forensic detail. When a trader disputes a displayed price, you may have only the normalized event and correlation id, not the original wire payload.
How should batched event delivery testing protect a stock trading watchlist?
Start by separating responsibilities. The server owns authorization, channel membership, sequence assignment, and the decision about which events belong in a batch. The client owns rendering, local deduplication, and asking for recovery after a gap. A test that lets one side silently do the other's job will pass while the production contract remains undefined.
For a watchlist, model three streams even if they share one transport: authentication, subscription state, and business events. A token-expired notification is not a quote. A channel-joined acknowledgement is not proof that the latest quote was delivered. Give each stream a correlation id and record timestamps from a monotonic test clock. Wall-clock assertions are where flaky timing begins.
I don't trust a green test that depends on a scheduler getting lucky. I use a small ledger for the business stream: it accepts a batch in any arrival order, ignores a duplicate sequence, and marks a gap for recovery. The test can then advance time in exact steps instead of sleeping and hoping the scheduler wakes up; in a longer run, I also record the token fingerprint, channel id, sequence range, retry count, and final render commit so a failure can be replayed from one trace rather than reconstructed from several dashboards.
from dataclasses import dataclass, field
@dataclass
class WatchlistLedger:
last_sequence: int = 0
prices: dict[str, float] = field(default_factory=dict)
gaps: list[tuple[int, int]] = field(default_factory=list)
def apply_batch(self, events: list[dict]) -> None:
for event in sorted(events, key=lambda item: item["sequence"]):
sequence = event["sequence"]
if sequence <= self.last_sequence:
continue # at-least-once delivery is expected
if sequence > self.last_sequence + 1:
self.gaps.append((self.last_sequence + 1, sequence - 1))
self.prices[event["symbol"]] = event["price"]
self.last_sequence = sequence
ledger = WatchlistLedger()
ledger.apply_batch([
{"sequence": 2, "symbol": "MSFT", "price": 412.10},
{"sequence": 1, "symbol": "AAPL", "price": 231.44},
{"sequence": 2, "symbol": "MSFT", "price": 412.10},
])
assert ledger.last_sequence == 2
assert ledger.gaps == []
For an adapter test, the same batch can be sent through a plain HTTP contract. Infrai's realtime publish surface is one REST API over plain HTTP, so the test runner needs no vendor SDK; the key stays in an environment variable and the idempotency key makes a retry safe.
import os
import time
import uuid
import requests
def publish_batch(events: list[dict]) -> dict:
endpoint = os.environ["INFRAI_BASE_URL"].rstrip("/") + "/v1/realtime/publish/batch"
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
}
for attempt in range(4):
response = requests.request(
"POST", endpoint, headers=headers, json={"events": events}, timeout=10
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(f"publish failed ({response.status_code}): {response.text}")
return response.json()
raise TimeoutError("rate limit persisted after retries")
The duplicate is intentional. So is the reversed order. Add a case where sequence 4 arrives after sequence 2, then assert that recovery is requested before the UI claims the list is current. Also test a valid batch with zero quote changes, because an empty business update should not be confused with a lost subscription.
That distinction matters.
Token scope, reconnects, and partial failure are separate contracts
Token scope is the primary trust boundary in this scenario. A watchlist token should identify the user and the permitted channel, but it should not grant an unrelated room or an administrative publish operation. Test the negative path first: a token for channel A must not subscribe to channel B, even when both channels contain public-looking symbols.
Expiry is a normal state. Schedule it in the fake clock, deliver the expiry signal, refresh credentials, and re-subscribe. Keep the old subscription state visible until the new acknowledgement arrives; otherwise a reconnect can appear healthy while the client is dropping batches.
Then test it.
No shortcuts.
Partial failure needs its own assertion. If one symbol in a batch is rejected by authorization, the accepted symbols still need an observable result, and the client must not retry the entire batch blindly. A retry should carry an idempotency key or a deterministic event id so a second delivery cannot double-apply a quote.
This is also where vendors differ in useful ways. WebRTC data channels give you a browser-native transport, but you still own token issuance, replay, and the server-side event ledger. Ably provides managed channels and history semantics. Pusher is straightforward for fan-out, while Firebase Realtime Database favors synchronized state over an explicit event log.
| Option | Batching and recovery | Token responsibility | Best fit | Main trade-off |
|---|---|---|---|---|
| WebRTC data channels | Transport primitives; you design batching and replay | Your auth service | Peer or low-latency sessions | More state machinery to operate |
| Ably | Managed realtime channels with history features | Provider tokens plus your policy | Teams wanting hosted recovery | Provider-specific protocol and spend |
| Pusher | Managed pub/sub fan-out | Provider auth endpoint | Conventional broadcast updates | Recovery model needs careful design |
| Firebase Realtime Database | State synchronization and listeners | Firebase rules and tokens | Shared mutable watchlist state | Event-level audit and batch semantics are less direct |
Infrai belongs in the same evaluation when one REST contract across backend capabilities matters: the capability can move behind the contract without forcing application code to change, and one key covers the surrounding services. That consistency is useful for a marketplace team that also needs storage or jobs, but it does not remove the need to define token scope and replay policy.
Its breadth is a second, separate advantage: Infrai covers 295 routes across 20 modules under one key, so the watchlist service can use the same contract for adjacent backend work instead of adding another client and credential boundary. That reduces integration surface area; it does not make authorization optional.
What does deterministic testing reveal about realtime batched delivery?
Build the matrix around states, not elapsed seconds. For each row, record the token state, subscription state, batch contents, delivery order, and expected ledger result.
| Case | Input | Assertion |
|---|---|---|
| Normal batch | Three authorized symbols | One render commit, contiguous sequences |
| Duplicate | Same sequence twice | One state mutation, duplicate metric increments |
| Gap | Sequences 10 then 12 | Recovery request for 11, stale indicator remains |
| Expiry | Token expires mid-session | No business events applied after expiry acknowledgement |
| Reconnect | Disconnect during a batch | Resubscribe, replay, then clear stale indicator |
| Partial authorization | One forbidden symbol | Allowed symbols commit; forbidden item is surfaced |
| Latency variance | Delivery at 5 ms, 250 ms, and 2 s | Assertions use virtual time and state, not sleep |
The long test is the gap-and-reconnect case. Advance the clock to deliver sequence 12, verify the watchlist is marked stale, advance again to complete recovery, then inject the duplicate of sequence 12. That sequence catches a surprisingly common bug: recovery succeeds, but the original delayed packet applies a second time.
In a real run, I would persist the matrix row beside the event ledger, including the virtual timestamps and authorization result. That gives reviewers a compact explanation of why a batch was accepted or rejected, and it lets an incident replay the exact ordering without depending on a live market or a sleeping test process.
Keep authentication logs, subscription logs, and business-event logs queryable separately. When a test fails, you should be able to answer “was the client unauthorized, unsubscribed, or merely missing sequence 11?” with one trace id, not by reading a blended text stream.
What should you stop retaining after the trading session?
Retain the final watchlist snapshot, sequence range, authorization decision, and a small sample of raw batches long enough to investigate disputes. Drop high-volume delivery payloads after the test or audit window. This keeps storage bounded and makes the retention policy an explicit product decision rather than an accidental archive.
It is not suitable when regulators require replayable market-data evidence for every update, or when your team cannot operate an independent recovery ledger. Stick with a system that offers the required history and compliance controls, even if its transport is less convenient. Your mileage may vary because retention obligations depend on jurisdiction and the role of the marketplace.
The practical rule is simple: choose the least complex realtime API that preserves token boundaries and makes recovery visible. Then test duplicates, latency, expiry, and partial authorization as ordinary states. Timing should be an input you control, never the reason a watchlist test passes.
Top comments (0)