When a live marketplace dashboard falls behind under load, debug the publish rate before changing batching. Track sequence gaps at each handoff, then measure queue age. That split tells you whether updates are missing or merely late, including after a reconnect.
TL;DR: debug publish rate first when accepted updates stop keeping pace with generated updates; debug batching first when throughput is adequate but queue age or flush delay grows. Reconnect and backfill belong in the test, because a dashboard can look healthy for connected clients while returning stale presence to everyone who briefly went offline.
| First move | Choose it when | Signal to capture | Main risk |
|---|---|---|---|
| Rate-first trace | Generated, accepted, and delivered counts diverge | Monotonic sequence at producer, broker boundary, and client | More instrumentation work |
| Batch-first trace | Counts converge, but delivery age rises | Oldest-item age, batch fill, and flush reason | Hiding an upstream loss |
The recommendation is rate-first tracing. It separates missing work from late work with less guesswork. Bigger batches are the runner-up, useful only after the sequence ledger proves that updates are not disappearing.
Loss and delay are different.
How should I debug a dashboard that falls behind under publish load?
Presence is a state-reconciliation problem wearing a streaming costume. A marketplace member opens workspace ws_42, disconnects after sequence 1842, and returns later. The client needs either every relevant change after 1842 or a fresh snapshot with a newer watermark. Sending only the newest live event is insufficient: it says nothing about members whose status changed during the gap.
This is where a load test often lies. A steady connected client exercises the happy path. It does not exercise the race between backfill and new live updates. Add reconnecting clients, record the last applied sequence, and assert eventual equality with an authoritative roster snapshot. No vibes. Compare sets. For one concrete fixture, let workspace ws_42 disconnect after sequence 1842; change several member states while it is away; reconnect it while more changes are being published; then compare its final member-ID set and watermark with the authoritative snapshot. This fixture does not manufacture a benchmark result. It creates a repeatable correctness check that can run beside whatever load shape the marketplace actually expects.
Start there.
Transport choice does not remove that obligation. WebRTC defines peer connections and data channels, but an application still has to decide what its messages mean, how order is represented, and how missed state is recovered. The same application-level questions apply when the delivery path uses another real-time transport.
A useful test has three phases with intentionally uneven pressure: establish the roster, pause a client while presence changes, then reconnect while new changes continue. Values such as 500 simulated members, a 20-second pause, and sequence 1842 are example test inputs, not universal targets. The target must come from the marketplace's own concurrency and recovery objective. My rule is to benchmark the smallest honest system boundary first, because a giant end-to-end run can prove that lag exists without showing who owns it.
Trace the publish rate as a ledger
One throughput number is too polite. Record counters at the points where ownership changes: generated, accepted for publication, persisted or retained for recovery, emitted, received, and applied. Attach a workspace ID and monotonic workspace sequence. Then compare deltas over the same interval. This instrumentation has a limitation: it adds code and telemetry volume, while a full per-workspace trace can be too expensive at normal traffic. Sample detailed traces, retain aggregate boundary counters, and temporarily increase sampling for a reproducible workspace. If the system cannot assign an authoritative sequence, this method is unsuitable as written; use a snapshot revision or another comparable monotonic cursor before claiming that a gap has been found.
If generated - accepted grows, the producer or admission boundary is the first suspect. If accepted work accumulates while emitted work stalls, inspect the queue and publisher. If emitted and received converge but applied falls behind, the browser's handler, rendering cadence, or stale-event rejection deserves attention. These are hypotheses tied to boundaries, not a vendor checklist.
Count first. Tune second.
Also measure age. Throughput can look flat while latency becomes awful because a queue is processing yesterday's shape of traffic with today's backlog. The oldest unpublished item age and the age at client application expose that failure quickly. Percentiles matter more than an average here; a marketplace operator notices the frozen tail, not the mathematically comfortable middle.
Keep dimensions controlled. Workspace ID is useful during a targeted trace, but unbounded IDs in long-lived metric labels create their own operational mess. Put high-cardinality identifiers in sampled traces or structured logs, and keep aggregate counters grouped by bounded attributes such as region, message class, and result.
Batch on time and size, then report why
Batching trades calls for waiting. That is the whole deal. A batcher needs a maximum item count and a maximum wait; otherwise a quiet workspace may hold a half-full batch indefinitely, while a busy workspace may build an enormous payload. Every flush should state whether size, time, shutdown, or backpressure caused it.
This TypeScript example keeps the policy visible. The values are sample configuration, deliberately easy to change and benchmark. Production code also needs bounded memory, cancellation, retry policy, and durable recovery appropriate to its storage boundary.
type PresenceChange = {
workspaceId: string;
sequence: number;
memberId: string;
online: boolean;
occurredAtMs: number;
};
type FlushReason = "size" | "time" | "shutdown";
class PresenceBatcher {
private pending: PresenceChange[] = [];
private timer: ReturnType<typeof setTimeout> | undefined;
constructor(
private readonly maxItems: number,
private readonly maxWaitMs: number,
private readonly publish: (
changes: readonly PresenceChange[],
reason: FlushReason,
) => Promise<void>,
) {}
add(change: PresenceChange): void {
this.pending.push(change);
this.timer ??= setTimeout(() => void this.flush("time"), this.maxWaitMs);
if (this.pending.length >= this.maxItems) {
void this.flush("size");
}
}
async flush(reason: FlushReason): Promise<void> {
if (this.pending.length === 0) return;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
const batch = this.pending;
this.pending = [];
await this.publish(batch, reason);
}
async close(): Promise<void> {
await this.flush("shutdown");
}
}
There is a trap in this compact code: overlapping asynchronous flushes can complete out of order. A real implementation must serialize publication per workspace or make sequence-aware consumers reject regressions and request recovery. Merely preserving array order inside one batch does not preserve order across batches. The code is also a teaching slice, not a drop-in queue: it does not cap pending, retry failed publication, or restore unsent items after a process exit. A durable queue is the better alternative when losing accepted presence changes across a restart violates the recovery contract, though it brings storage latency and more operational surface.
For each flush, observe batch item count, encoded bytes, queue wait, publish duration, result, and reason. Benchmark the complete path with realistic payload distributions. A fixed event count can mask one workspace sending much larger member metadata than another, so both count and bytes belong in the report.
Reconcile backfill without racing live traffic
Use a snapshot watermark. During reconnect, obtain a roster snapshot that represents state through sequence S, buffer or retain live changes after S, apply the snapshot, then apply changes greater than S in sequence order. Duplicate delivery is harmless only if applying a change is idempotent. A gap is not harmless; it should trigger another bounded recovery attempt rather than silently advancing the watermark.
That contract should be expressible without knowledge of the transport:
type PresenceSnapshot = {
throughSequence: number;
onlineMemberIds: readonly string[];
};
type RecoverySource = {
snapshot(workspaceId: string): Promise<PresenceSnapshot>;
changesAfter(
workspaceId: string,
sequence: number,
): AsyncIterable<PresenceChange>;
};
async function recover(
source: RecoverySource,
workspaceId: string,
replaceRoster: (memberIds: readonly string[]) => void,
apply: (change: PresenceChange) => void,
): Promise<number> {
const snapshot = await source.snapshot(workspaceId);
replaceRoster(snapshot.onlineMemberIds);
let applied = snapshot.throughSequence;
for await (const change of source.changesAfter(workspaceId, applied)) {
if (change.sequence <= applied) continue;
if (change.sequence !== applied + 1) {
throw new Error(`presence gap after ${applied}`);
}
apply(change);
applied = change.sequence;
}
return applied;
}
The error is intentional. Pretending a gap did not happen produces a fast, wrong dashboard. The caller can retry from a new snapshot under a bounded policy, while telemetry records recovery duration, attempts, and the final sequence.
Deploy this contract with fault injection: delay publication, force partial batches, disconnect clients around a flush boundary, duplicate a change, and remove one sequence. The pass condition is state convergence plus an explicit alert for unrecoverable gaps. CPU and request counts are supporting evidence. Correct roster state is the result.
When is batch-first the better call?
Choose the runner-up when the ledger already balances across boundaries and the oldest-item age climbs specifically inside a known batching stage. It is also reasonable when traces show mostly tiny, timer-triggered flushes and publication overhead dominates the measured path. Change one control at a time, then rerun reconnect recovery as well as steady load.
Do not raise the batch ceiling merely because the dashboard lags. Larger batches can extend wait for low-traffic workspaces, increase retry scope, and make one slow publication block more changes. The right setting is the smallest combination of size and time limits that meets the measured recovery and freshness objectives under the expected distribution. There is no honest magic number.
The final decision rule is blunt: sequence deltas locate loss; age locates delay. Prove which one is happening, preserve reconnect correctness, and only then tune the batcher.
Further reading
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Top comments (0)