TL;DR: Use one session-scoped broadcast channel for poll questions and aggregate results, then keep acknowledgments and device status on a device-addressed path. Do not create a separate transport connection for every logical channel. This hybrid makes fan-out cheap to reason about while keeping retries, slow consumers, and private status isolated. Choose a different split only after load and recovery tests show a concrete reason.
A live classroom poll has two very different flows hiding behind the word realtime. A teacher publishes one question to every student device. Each device sends an answer and periodic status back. Treating those flows as symmetrical is where operations get painful: broadcast delivery and per-device recovery have different failure domains.
The delivery contract should be explicit before code exists: question delivery is at least once, answers are accepted at least once but deduplicated, and the displayed aggregate is a versioned snapshot. Exactly-once end-to-end delivery is not assumed. A reconnect can repeat a frame, and an acknowledgment can disappear after the server commits an answer.
Should one channel serve all devices or one per device?
A single shared channel is attractive because subscription state stays small and publishing a question is one logical operation. It is also a broad failure domain. If answers, presence, acknowledgments, and questions all share one ordered stream, a burst of answer traffic can delay the next question or its close event. One slow device should not define progress for the class.
Per-device channels reverse the trade-off. They isolate targeted acknowledgments and make it natural to replay one student's missing state, but the server now owns more subscription metadata and more opportunities for stale membership. Publishing the same question separately to every device also complicates partial failure: the operator must distinguish a slow fan-out from a device that was never eligible.
The useful boundary is semantic, not physical. Keep session/{sessionID} for public, versioned poll state. Route device/{deviceID} messages for answer acknowledgments, resync instructions, and status. Both logical paths can be multiplexed over one WebSocket or one WebRTC data transport per device. WebRTC explicitly supports multiple data channels over an SCTP association, so a logical channel does not require another network connection.
Isolation has a price.
This gives the runbook a crisp rule: if a message has identical authorized content for the whole class, publish it once at session scope. If processing or visibility differs by device, address the device.
Implement the idempotency boundary first
The server must assign a monotonically increasing poll version within a session. The client supplies a stable answer ID for every submission and reuses it after a timeout. The write path records that ID before returning an acknowledgment. Duplicate delivery then becomes an expected branch, not an incident.
The following Go program is a runnable in-memory model of that boundary. Production storage needs the same uniqueness constraint on (session_id, answer_id) and an atomic commit of the answer plus its deduplication record.
package main
import (
"encoding/json"
"fmt"
"sync"
)
type Answer struct {
SessionID string `json:"session_id"`
PollID string `json:"poll_id"`
DeviceID string `json:"device_id"`
AnswerID string `json:"answer_id"`
Choice string `json:"choice"`
Version uint64 `json:"version"`
}
type Store struct {
mu sync.Mutex
seen map[string]struct{}
}
func (s *Store) Accept(a Answer) (duplicate bool) {
s.mu.Lock()
defer s.mu.Unlock()
key := a.SessionID + "/" + a.AnswerID
if _, ok := s.seen[key]; ok {
return true
}
s.seen[key] = struct{}{}
// Persist the answer in the same transaction as this uniqueness record.
return false
}
func main() {
store := Store{seen: make(map[string]struct{})}
raw := []byte(`{"session_id":"algebra-7","poll_id":"p4","device_id":"tablet-23","answer_id":"a-9f2","choice":"B","version":4}`)
var answer Answer
if err := json.Unmarshal(raw, &answer); err != nil {
panic(err)
}
fmt.Println("duplicate:", store.Accept(answer))
fmt.Println("duplicate:", store.Accept(answer))
}
An answer ID must survive reconnects. Generating a new ID on every retry defeats the entire boundary. Likewise, device_id is routing metadata, not proof of identity; authentication and session authorization must happen before the message reaches this handler.
The aggregate update should carry poll_id and version. A client discards an older version and replaces its local count with the newest snapshot rather than incrementing blindly. Delta-only aggregates look efficient until one frame is missed. Snapshots spend more bytes but make recovery bounded and inspectable, which is the better default for a classroom poll with short-lived state.
Separate fan-out from device recovery
Publish the question to the session channel once. Track delivery progress by poll version, not by the number of successful socket writes. A completed write only means bytes reached a local transport buffer; it does not prove that the student interface rendered the question. Application acknowledgments provide that stronger signal.
Do not block the session broadcast while waiting for every acknowledgment. Record acknowledgments on each device path and expose the lag distribution. A device that reports version 3 while the session is at version 4 receives the current snapshot on its private path. There is no need to replay the whole classroom stream.
A bounded queue is mandatory at both boundaries. For a session subscriber, coalesce replaceable aggregate snapshots by (poll_id, version) and never coalesce open or close transitions. For a device queue, preserve the newest resync instruction and acknowledgment. If the queue remains full, disconnect that consumer with a reason code and let it resume from its last applied version. Infinite buffering converts a slow client into a server-wide memory problem.
Bound it.
Here is a small Go helper that rejects stale snapshots and detects a gap. It is deliberately boring; recovery code should be.
package main
import "fmt"
type Cursor struct {
Version uint64
}
func (c *Cursor) Apply(incoming uint64) (applied, resync bool) {
if incoming <= c.Version {
return false, false
}
if incoming != c.Version+1 {
return false, true
}
c.Version = incoming
return true, false
}
func main() {
cursor := Cursor{Version: 4}
applied, resync := cursor.Apply(6)
fmt.Printf("applied=%v resync=%v version=%d\n", applied, resync, cursor.Version)
}
The apparent gap may be harmless when messages are snapshots. Even then, requesting the authoritative current state is safer than guessing, and it yields one recovery path that can be exercised before an incident.
Verify delivery under failure
Start with invariants. For one authenticated device and poll version, the durable store contains zero or one answer for an answer ID. Closing a poll prevents later answers from entering the aggregate. A device that reconnects with an older cursor eventually receives the current version. No private acknowledgment appears on the session channel.
Then test timing, because happy-path unit tests do not cover fan-out. Run a session with representative device concurrency and inject three failures independently: delay one consumer until its queue fills, sever a connection after the answer commit but before the acknowledgment, and restart a fan-out worker while a question is open. The expected results are specific. The slow consumer is isolated, the repeated answer is marked duplicate without changing the aggregate, and all reconnecting devices converge on the same poll version.
Watch four signals during the exercise:
- acknowledgment lag by session and poll version, reported as a distribution rather than one average;
- count of connected devices whose applied version trails the current version;
- deduplicated answer attempts;
- queue saturation and disconnects by reason.
Avoid device IDs in metric labels. That creates unbounded cardinality and turns a debugging dimension into an observability failure. Put a sampled device ID in structured logs or traces, correlated with session, poll, version, and answer ID. Metrics should retain bounded labels such as message type and disconnect reason.
A zero duplicate count is suspicious during retry testing. It usually means the failure was not injected across the commit-and-ack boundary, or the client generated a fresh ID.
Roll out and roll back without losing the poll
Ship the hybrid routing behind a server-side session cohort flag. During a shadow phase, compute the intended destination and queue decision without sending a second message; compare counters for session broadcasts, targeted recoveries, rejected stale updates, and authorization failures. Shadowing must not write answers or acknowledgments.
For the first active cohort, pin routing mode for the lifetime of a classroom session. Changing channel semantics halfway through an open poll creates ambiguous cursors and makes rollback harder. New sessions can move between cohorts; active sessions finish on the mode they started with.
Rollback means stopping admission to the new mode, not deleting channel state. Keep the old reader compatible with versioned snapshots until the longest permitted session and reconnect window have elapsed. If the lag or queue-saturation threshold in the deployment runbook is crossed, freeze expansion, capture the affected session and poll versions, and route new sessions through the previous mode. The poll data remains authoritative in storage; channels are delivery paths, never the source of truth.
The final choice is therefore narrow. Shared session channels handle identical fan-out. Device-addressed paths handle individualized recovery and status. The operational win comes from versioned state, idempotent writes, bounded queues, and a rollback boundary aligned with a classroom session, not from maximizing or minimizing the raw channel count.













