Short answer: keep vote ingestion separate from interval publication, assign every published tally a monotonic revision, and let a reconnecting viewer request the newest snapshot after the last revision it applied. For a live media poll, the least complex dependable design is one authoritative aggregator per poll, a durable vote log or database behind it, and disposable realtime connections in front of it. The five-second ticker is presentation cadence, not correctness.
The page that should fire is tally_revision_stalled, scoped to one poll and accompanied by the last accepted vote position, the last published revision, connected viewers, and publish age. The on-call does not need a bright chart saying connections are down 8%. They need to know whether votes are still being accepted while viewers are seeing an old result, because that is the split-brain experience an audience will notice during a broadcast.
This design also answers the Node.js implementation question without tying correctness to an Express process timer. Express can validate and accept a vote; a serialized poll worker owns aggregation; a scheduler asks that worker to publish; and the transport delivers a versioned snapshot. If a process restarts or a viewer reconnects, both resume from durable positions instead of guessing what happened during the gap.
What should page the on-call?
Imagine the alert arriving with only WebSocket disconnect rate high. That signal is noisy by construction: mobile viewers roam between networks, laptops sleep, and browsers close. A disconnect is an event, not yet an incident. The actionable failure is narrower: accepted votes have advanced beyond the input position represented by the most recent published tally, and that gap has remained open longer than the promised publication interval plus a small scheduling allowance.
The alert payload should make the failure legible without dashboard archaeology:
- poll and broadcast identifiers
- latest durable vote position
- input position included in the latest tally
- tally revision and publication timestamp
- worker lease owner, if ownership is leased
- publish attempts and delivery failures
Ask one blunt question: what page fired? If the answer is merely CPU, open connections, or queue depth, the team still has to infer user impact. Page on a sustained publication lag while votes are arriving. Ticket or graph the rest.
The earlier signal is accepted_position - published_position, measured per poll. Publication age alone cannot distinguish a quiet poll from a stuck worker. Vote throughput alone cannot show that viewers are stale. The position gap joins the two sides of the contract, so it should fire before audience reports arrive.
How should Node.js aggregate votes and publish each interval tally?
A vote command needs a stable identifier, poll identifier, selected option, and an authoritative position assigned when the command becomes durable. Reject choices outside the poll definition before appending. Deduplicate by the stable vote identifier according to the product's voting rule; a rule allowing one active choice per account requires an identity and replacement policy, while an anonymous multiple-choice poll has a different state transition. Do not pretend those policies are interchangeable.
The aggregator consumes commands in position order and produces a complete snapshot. A snapshot carries the poll identifier, counts, the highest included input position, and a monotonic tally revision. Complete snapshots cost more bytes than deltas, but they make reconnect recovery bounded: the client can replace local state after verifying that the revision is newer. Deltas demand gap detection and replay, and one lost increment can poison every later display. For a small option set, the snapshot trade-off is easier to operate.
That is the invariant.
The state transition below is Go because the important part is ownership and revision, not a framework API. A Node.js service should preserve the same single-writer semantics through one queue partition, a database transaction, or another explicit serialization boundary.
package tally
import (
"errors"
"sync"
"time"
)
type Vote struct {
ID string
Option string
Position uint64
}
type Snapshot struct {
PollID string `json:"pollId"`
Revision uint64 `json:"revision"`
IncludedPosition uint64 `json:"includedPosition"`
PublishedAt time.Time `json:"publishedAt"`
Counts map[string]int `json:"counts"`
}
type Aggregator struct {
mu sync.Mutex
pollID string
allowed map[string]bool
seen map[string]bool
counts map[string]int
position uint64
revision uint64
}
func (a *Aggregator) Apply(v Vote) error {
a.mu.Lock()
defer a.mu.Unlock()
if !a.allowed[v.Option] {
return errors.New("unknown poll option")
}
if a.seen[v.ID] {
return nil
}
if v.Position <= a.position {
return errors.New("non-monotonic input position")
}
a.seen[v.ID] = true
a.counts[v.Option]++
a.position = v.Position
return nil
}
func (a *Aggregator) Publish(now time.Time) Snapshot {
a.mu.Lock()
defer a.mu.Unlock()
a.revision++
copied := make(map[string]int, len(a.counts))
for option, count := range a.counts {
copied[option] = count
}
return Snapshot{
PollID: a.pollID, Revision: a.revision,
IncludedPosition: a.position, PublishedAt: now.UTC(),
Counts: copied,
}
}
The mutex makes this sample internally coherent, but it does not make two processes one writer. That trap matters. If every Express replica runs its own interval and holds its own counts, viewers can receive alternating realities; adding a distributed transport spreads the contradiction faster. Elect or partition one owner for a poll, persist the consumed position and snapshot atomically, and treat publication as retryable delivery of already committed state.
Timers are not authority.
How does reconnect and backfill stay boring?
A client stores the greatest tally revision it has applied. On connection, it sends that revision as resume metadata. The server returns the current snapshot when the stored revision is absent or older; if the client already has the current revision, no tally transfer is necessary. A late message with a lower revision is discarded. An equal revision is idempotent. A higher revision replaces the displayed counts.
That rule is intentionally dull.
WebRTC data channels are one possible transport, and the WebRTC specification defines data channels for bidirectional arbitrary data, but transport choice does not remove the application-level revision contract. The same contract can sit above WebSocket, server-sent events for downstream delivery, or periodic HTTP retrieval. Keep vote submission on an authenticated request path with explicit acknowledgement rather than assuming a transient viewer connection proves a vote became durable.
Backfill does not need to mean replaying every five-second publication. A viewer wants the latest correct tally, so one current snapshot is enough unless the interface exposes historical movement. Analytics is different: if producers need the full time series, retain committed snapshots or derive them from the durable vote log under a stated retention policy. Do not overload reconnect recovery with an analytics requirement most viewers never use.
| Incoming revision | Client action | Operational meaning |
|---|---|---|
| lower than applied | discard | delayed or reordered delivery |
| equal to applied | ignore safely | duplicate delivery |
| exactly one higher | replace snapshot | normal interval update |
| more than one higher | replace and record a gap | reconnect or missed delivery |
Recording a gap is useful telemetry, but a gap between complete snapshots is not a reason to freeze the UI. The newer snapshot is authoritative. This distinction keeps recovery fast while leaving evidence for investigation.
Instrument the contract, then test failure
Start with four measurements: accepted vote position, aggregated position, committed tally revision, and delivered revision acknowledgements sampled from clients. The first three locate a server-side stall. The last reveals an audience segment that is connected but not advancing. Include broadcast and poll identifiers in logs, but avoid attaching voter identity to high-cardinality metrics.
Emit one structured record at each boundary using the same poll identifier and positions: vote committed, vote applied, snapshot committed, publication attempted, and client revision observed. This creates an alert-to-action trace. An operator can move backward from a stale client revision to a failed delivery, an unpublished committed snapshot, a stalled aggregator, or an ingestion gap without correlating unrelated panels by eye.
Test the trace with failures. Kill the aggregation owner immediately after snapshot commit and before delivery. Deliver revision 42 after revision 43. Submit the same vote identifier twice. Disconnect a client for three publication intervals, then resume it with revision 39 while the server holds 43. Restart an Express replica and verify that its local timer cannot create a second authority. Each test should assert displayed counts and positions, not merely that a callback ran.
Deployment needs the same discipline. Introduce revision fields as optional readers first, then publish them, then require them after old clients age out. During rollback, a previous server must not emit an unversioned message that a newer client mistakes for fresh state. Schema compatibility belongs in the incident plan because reconnects surface older client builds.
The threshold can create its own incident
A five-second publication interval should not translate into a page at 5.001 seconds. Runtime pauses, scheduler jitter, and temporary delivery retries exist, while a quiet poll can legitimately publish no changed tally if that behavior is documented. Define the alert around a sustained position gap across more than one expected publication opportunity, and validate it under the slowest supported deployment conditions. The exact allowance is a service objective decision, not a universal constant.
Too loose, and viewers watch stale numbers while votes accumulate. Too tight, and every brief pause wakes someone who can take no useful action; repeated false positives train the team to distrust the one page that matters. Track how often the alert corresponds to an actual breached viewer-freshness objective, then revise either the threshold or the instrumentation when it does not.
The operational conclusion is compact: make vote acceptance durable, serialize aggregation per poll, publish complete versioned snapshots, and recover reconnects by revision. The interval controls how often the picture changes. It must never be the only place the truth lives.
Further reading
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Top comments (0)