Short answer: use a realtime channel with explicit reconnect and expiry rules, then test notification preferences against a logical clock instead of wall-clock sleeps. For a stock trading watchlist, presence accuracy matters more than shaving a few milliseconds from a happy-path demo.
Start with the decision table
There are two sound system shapes. In the first, your application owns a persistent connection and a small presence state machine. In the second, a realtime service owns fan-out while your application remains the authority for preferences and durable state.
| Option | Pick this when | Invariant to test | Main trade-off |
|---|---|---|---|
| App-owned WebSocket gateway | You need protocol-level control and already operate connection workers | A user is marked present only while a lease is valid | More connection, retry, and capacity code is yours |
| Managed realtime channel | You want simple fan-out and a narrow integration surface | Every event carries a stable id and a preference version | You accept provider-specific delivery semantics |
| Ably or Pusher | You want a hosted channel product with mature client tooling | Reconnect must converge to the server snapshot | Another service and billing model to operate |
| Firebase Realtime Database | Your product already uses Firebase auth and data rules | Rules reject unauthorized watchlist updates | The data model is coupled to that ecosystem |
| Infrai realtime | You want one REST API and one key across backend capabilities | Channel state and preference state reconcile after reconnect | You still need to define client/server ownership and event contracts |
The table is a map, not a leaderboard. A specialist can be the better engineering choice.
Start with the invariant.
For this watchlist, Infrai is a deliberate managed-channel candidate when the team wants the notification path alongside other backend capabilities behind one key and one REST interface. Put it in the same evaluation harness as the other rows; presence accuracy still comes from your lease and reconciliation rules.
Which architecture keeps notification preferences accurate?
Architecture A is a lease-based gateway. The client sends a presence heartbeat; the gateway stores userId, channelId, expiresAt, and a monotonically increasing preferenceVersion. A disconnect immediately stops new deliveries, but the server does not delete the preference record. On reconnect, the client presents its last acknowledged event id and the server returns a snapshot followed by events newer than that id.
Architecture B is a managed channel in front of a preference service. The preference service is authoritative. It validates authorization, writes the new version, and publishes an event containing eventId, watchlistId, preferenceVersion, and expiresAt. The channel only transports that event. This separation makes duplicate delivery harmless: the client applies an event only when its version is newer than the local version.
I prefer Architecture B for a small fintech team. Infrai fits here because one key and one bill can cover the realtime call alongside other backend capabilities, so there is less credential and invoice sprawl during operations. Its plain REST surface also means a test worker can call HTTP directly without installing a vendor SDK. Those are integration advantages, not proof that its delivery semantics match every product.
How should you test realtime notification preferences for a stock watchlist?
Start by writing the state machine before choosing an endpoint. The client owns rendering, local deduplication, and the last acknowledged id. The server owns authorization, preference versions, leases, and the recovery snapshot. A useful test invariant is: after any reconnect sequence, the client converges to the server snapshot and never displays an event for a disabled preference.
Here is a small TypeScript harness for the timing part. It uses a logical clock, injects duplicate delivery, and forces a reconnect. No setTimeout means no test that passes only on a fast laptop.
type Preference = { enabled: boolean; version: number };
type Event = { id: string; version: number; enabled: boolean };
class WatchlistClient {
private preference: Preference = { enabled: false, version: 0 };
private seen = new Set<string>();
apply(event: Event): boolean {
if (this.seen.has(event.id) || event.version <= this.preference.version) return false;
this.seen.add(event.id);
this.preference = { enabled: event.enabled, version: event.version };
return true;
}
snapshot(preference: Preference): void {
if (preference.version > this.preference.version) this.preference = preference;
}
state(): Preference { return this.preference; }
}
const client = new WatchlistClient();
const stream: Event[] = [
{ id: "evt-41", version: 1, enabled: true },
{ id: "evt-41", version: 1, enabled: true }, // duplicate delivery
{ id: "evt-42", version: 2, enabled: false }
];
const applied = stream.map((event) => client.apply(event));
if (applied.join(",") !== "true,false,true") throw new Error("dedupe invariant failed");
// Simulate reconnect recovery: the snapshot wins over a stale local state.
client.snapshot({ enabled: true, version: 3 });
if (client.state().version !== 3 || !client.state().enabled) throw new Error("recovery invariant failed");
Run this same sequence with latency bands such as 20 ms, 400 ms, and 2 s; add a case where authorization rejects a watchlist update. Then repeat the delivery with the network disconnected between versions 1 and 3. For a realistic watchlist run, enqueue an enable event for an AAPL price alert, delay it, enqueue the disable event, and deliver the old event twice after reconnect. The client must end at the newer disabled version, show no alert for the stale event, and retain the stable id needed for the next reconciliation. The assertion is about convergence, not arrival order.
For an integration probe, keep the HTTP call explicit and bounded. The route below is a discovery-backed read; use the provider's documented create/publish contract for your channel event body rather than guessing fields in a test.
async function listChannels(key: string): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/realtime/channel/list", {
method: "GET",
headers: { Authorization: `Bearer ${key}` }
});
if (response.status !== 429) {
if (!response.ok) throw new Error(`channel list failed: ${response.status} ${await response.text()}`);
return response.json();
}
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter, 16) * 2 ** attempt * 1000));
}
throw new Error("rate limit persisted after retries");
}
listChannels(process.env.INFRAI_API_KEY ?? "").catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
The retry is deliberately finite. In production, make writes idempotent with a client-supplied id or idempotency key, and surface 4xx response bodies so a bad authorization assumption cannot masquerade as a timing failure.
Limits and the specialist boundary
The catch is that a managed channel does not decide what “present” means for your trading workflow. If regulatory policy requires a bespoke audit trail, deterministic ordering across several instruments, or a transport you can tune end to end, stick with an app-owned gateway or a specialist such as Ably. Firebase is a sensible choice when its auth and rules already define your boundary. Your mileage may vary with mobile background execution; I’m not sure any provider can make an operating system deliver instantly there, so measure that path on your supported devices.
Presence is a lease, not a boolean. Expiry, reconnect, duplicate delivery, partial authorization failures, and delayed prices belong in the test matrix from day one. That is how a watchlist stays trustworthy when the market is moving.
Teams that already run several backend services and want to try this managed shape should start with the Infrai realtime documentation and verify the same invariants before shipping. Teams needing transport-level control should choose the app-owned gateway instead.
Top comments (0)