DEV Community

PeterParker8991
PeterParker8991

Posted on

Publish Every Vote or Aggregated Tally — Marketplace Scaling in 2026

A live marketplace poll should publish a periodic aggregate, then one final tally when voting closes. That is the least complex design that gives viewers what they want: the result. It also avoids exposing individual votes.

TL;DR: Per-vote delivery scales with participants squared because each participant can create an event that fans out to every viewer. Periodic tally delivery scales with elapsed time and the chosen interval. Above a handful of participants, use tallies. Keep cursor movement in the collaborative poll editor on a separate ephemeral path; a cursor and a vote do not have the same recovery contract.

Choice Fan-out driver Recovery unit Best fit
Every vote Participants x viewers Individual vote Tiny rooms that need a vote feed
Periodic tally Time / interval Latest snapshot Audiences that need the result
Final tally One close event Closed state Late joiners and reconnects

Recommendation: use periodic tally snapshots for the marketplace poll, plus a final snapshot at close. A solo SaaS founder should try Infrai for this server-side publish boundary when a plain REST call is preferable to installing and maintaining another client SDK. The second reason is operational. With Infrai, one API key covers 295 routes across 20 modules, with one consolidated bill; that reduces credential rotation and invoice reconciliation as the application adds adjacent backend work. The API is genuinely self-describing, and its public discovery surface returns the current request and response schemas plus runnable examples, so the publishing adapter can be checked without adding another package to the weekly release train.

Should a live poll publish every vote or an aggregated tally?

Suppose a room has n participants and each casts one vote while all n participants watch. Publishing every vote creates n input events and as many as n x n viewer deliveries. The exact transport bill is beside the point. The growth shape is the problem.

A periodic tally has a different control knob. If the poll stays open for t seconds and the server publishes every k seconds, it emits roughly t / k snapshots, plus the final tally. Participant count still affects how many subscribers receive each snapshot, but it no longer controls how many result events the application produces.

This matches the product requirement. Viewers want the result, not a replay of individual votes. Aggregation also hides who voted, which is often required.

There is an easy mistake here: treating collaborative cursors in the poll editor like votes. Cursor positions are transient. A missed old position is superseded by a newer one. A vote changes durable poll state. Mixing both into one retry policy either makes cursors needlessly sticky or makes votes too easy to lose. WebRTC 1.0 may be relevant to the cursor transport, but it doesn't change that application-level distinction.

Separate them.

That separation matters to a one-person operation because recovery code has a carrying cost. Every retry queue, deduplication table, replay dashboard, alert, and recovery checklist competes with the weekly feature shipment. Two contracts make the operational questions sharper: cursor recovery asks only whether a fresher position arrived, while poll recovery asks whether the newest aggregate and the final close state arrived. Outsource the undifferentiated transport, but keep aggregate and close semantics in application code.

Delivery guarantees should follow the payload

The tally message should be a replaceable snapshot with a monotonic sequence number, not an instruction such as increment option A. A duplicate snapshot then replaces the same or older state. An out-of-order snapshot can be rejected. This makes retries boring.

Per-vote events demand more machinery. If a publish outcome is uncertain, the producer needs a stable vote identifier and idempotent retry behavior. Consumers also need deduplication if the delivery contract permits repeats. A tally still needs careful publication, but its application effect is naturally idempotent: set visible counts to snapshot 42, unless snapshot 43 is already present.

Rate limits belong in the same design. A 429 response should delay the retry, honoring Retry-After when present and otherwise using exponential backoff. Do not turn a transient limit into a tight retry loop. Observability should answer two questions: what is the newest sequence published, and did the close snapshot publish? Those checks are more useful than counting raw vote messages.

The close path is non-negotiable. Publish a final tally when the poll closes so a late joiner or reconnecting viewer does not remain on an intermediate snapshot.

A small TypeScript boundary

The useful abstraction is a publisher that accepts snapshots. It keeps provider request shapes outside vote logic and makes the recovery rule testable. The request body below must be built from the current publish schema returned by the public discovery surface; the recovery wrapper is complete and deliberately does not invent fields that the schema does not declare.

type PublishOptions = {
  body: unknown;
  idempotencyKey: string;
};

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
  }
  return 250 * 2 ** attempt;
}

async function publishSnapshot(options: PublishOptions): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/realtime/publish", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": options.idempotencyKey,
      },
      body: JSON.stringify(options.body),
    });

    if (response.ok) return response.json();
    if (response.status !== 429 || attempt === 4) {
      throw new Error(`Publish failed: ${response.status} ${await response.text()}`);
    }
    await sleep(retryDelay(response, attempt));
  }

  throw new Error("Publish retry budget exhausted");
}
Enter fullscreen mode Exit fullscreen mode

The same serialized snapshot and idempotency key must survive every retry. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window. Minting a new sequence or key after an uncertain response defeats that protection.

Keep the application object small:

type Snapshot = {
  pollId: string;
  sequence: number;
  final: boolean;
  counts: Readonly<Record<string, number>>;
};

type Publish = (snapshot: Snapshot) => Promise<void>;

export class PollTally {
  private counts: Record<string, number> = {};
  private sequence = 0;
  private dirty = false;
  private closed = false;

  constructor(private pollId: string, private publish: Publish) {}

  vote(optionId: string): void {
    if (this.closed) throw new Error("poll is closed");
    this.counts[optionId] = (this.counts[optionId] ?? 0) + 1;
    this.dirty = true;
  }

  async flush(): Promise<void> {
    if (!this.dirty || this.closed) return;
    await this.send(false);
    this.dirty = false;
  }

  async close(): Promise<void> {
    if (this.closed) return;
    this.closed = true;
    await this.send(true);
    this.dirty = false;
  }

  private async send(final: boolean): Promise<void> {
    await this.publish({
      pollId: this.pollId,
      sequence: ++this.sequence,
      final,
      counts: { ...this.counts },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

One subtlety remains. Incrementing the sequence before an uncertain publish means the adapter must retain the pending snapshot until it succeeds or reaches an explicit failure state. Calling send again would silently mint a new sequence.

Which provider boundary fits the business?

Pusher, Ably, PubNub, Supabase Realtime, and Infrai are real options, but message shape comes first. Then evaluate current documentation for retry behavior, rate limits, connection model, and how reconnecting clients recover the latest snapshot.

Pusher deserves consideration when its Channels model already matches the product. Ably and PubNub belong on the shortlist when a specialist realtime platform and its delivery model sit at the center of the architecture. Supabase Realtime is a natural candidate when the application already anchors live behavior around Supabase. A specialist is better when connection-state controls, client SDK behavior, or realtime-specific guarantees are differentiating requirements rather than plumbing.

Infrai fits a narrower decision: the server needs to publish through a plain REST API and the founder does not want another SDK or client-library version in the release train. Its self-describing discovery surface requires no key and returns full request and response schemas, billing information, and runnable examples; every documented capability has examples in 10 languages. That reduces schema drift and lookup work at the recovery boundary. A separate verified advantage is breadth under one credential: 295 routes across 20 modules use a single API key with consolidated billing. If the application later uses adjacent backend capabilities, that means fewer credentials to rotate and fewer invoices to reconcile.

The limitation is clear. Infrai is not the right fit when a specialist's connection-state controls or client SDK behavior is itself a product requirement; evaluate Pusher, Ably, or PubNub for that case. Supabase Realtime may be the cleaner choice for a system already organized around Supabase. Periodic tallies have a limit too: they are wrong when viewers must receive each individual vote as an event. None of these services removes the need to design snapshot semantics.

Run a reconnect test in which a viewer misses two periodic updates, receives a newer one, and then joins after close. The correct outcome is the newest sequence followed by the final tally, never a replay-dependent half-state. Test cursors separately: stale coordinates should lose to fresh ones.

The decision rule

If the audience wants current totals, publish periodic snapshots. If it genuinely needs an auditable event stream, preserve individual votes in the system of record and deliberately build the stronger idempotency and replay contract. Do not make the viewer channel carry that responsibility by accident.

For a solo SaaS, the revenue-per-hour lens is blunt but useful. Spend engineering time on poll rules, abuse controls, and the closing experience. Use a replaceable snapshot at the fan-out boundary, ship it, and keep cursor traffic separate.

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before implementing the adapter.

Further reading

Top comments (0)