The page says students in a live classroom auction have seen different winning bids after reconnecting. An instructor's screen shows the latest accepted bid, while a student's chat still shows the previous one. Short answer: commit the accepted bid and its room-local sequence number in one authoritative transaction, then broadcast that committed event; on reconnect, fetch the missing sequence range before showing the room as caught up. An Express handler can accept the request, but an HTTP 200 or a successful socket write cannot establish that every student received it.
This is a delivery problem, not a faster-socket problem. A WebSocket connection gives a channel for messages, while application-level recovery still needs an event identifier and a way to read events missed while disconnected. The WebSocket protocol defines framing and connection behavior, not durable room history or exactly-once application processing. See RFC 6455 in Further reading.
No receipt, no certainty.
What should have paged before the classroom diverged?
An alert on disconnected sockets comes late and can miss the actual failure: a connected client may also skip an event after a server restart or a failed fan-out. Measure the age of the oldest committed room event that has not reached the delivery worker, and count detected sequence gaps during client catch-up. Page on sustained backlog age against the room's delivery SLO; use a separate, lower-severity signal for a rising gap-recovery rate. The SLO and alert window need to come from measured classroom requirements, not a copied threshold.
First distinguish three positions: the room's committed sequence, the last sequence a fan-out worker has attempted, and the last contiguous sequence each client has applied. Those counters answer different questions. A broadcast counter that increments before the database transaction commits can advance even when the bid is rejected, leaving a gap that no reconnect can fill. Conversely, a committed event with no delivery attempt is a useful early warning while clients may still appear healthy.
For a live auction inside a course room, a bid request should carry an idempotency key, room ID, bidder identity, and offered amount. The server checks authorization and the current high bid inside the transaction, assigns the next room-local sequence only to an accepted bid, and persists an event with that accepted amount. The acceptance response returns the committed sequence. Other bidders learn the result from the room stream, not from trusting their own submitted amount. Two concurrent requests must serialize around the room's authoritative bid state; otherwise both can claim the same predecessor.
How should an accepted high bid broadcast carry its sequence number?
At the commit boundary. Publishing before commit lets students see a winning bid that later rolls back. Publishing only from the request handler after commit creates the opposite hole: the handler can die between commit and broadcast. A transactional outbox closes that hole by storing the accepted-bid event and the state update in the same transaction; a worker retries delivery afterward. The outbox does not make client delivery exactly once. A worker may send and then crash before recording progress, so consumers must tolerate duplicates. The transactional outbox pattern and its duplicate-delivery consequence are documented in Further reading.
For an Express implementation, keep the route thin: authenticate, pass the command and idempotency key to the transactional bid service, and return the committed sequence; a separate broadcaster drains the outbox. Here is the smaller, transport-independent piece of client-side recovery logic in Go. It never assumes a WebSocket callback represents a durable acknowledgment:
package room
import "errors"
var ErrGap = errors.New("fetch missing room events")
type Event struct {
RoomID string
Sequence uint64
Kind string
Amount int64 // Minor currency units, as stored by the server.
}
type Cursor struct {
RoomID string
Sequence uint64
}
func Apply(cursor *Cursor, event Event) error {
if event.RoomID != cursor.RoomID {
return errors.New("event belongs to another room")
}
if event.Sequence <= cursor.Sequence {
return nil // Retried delivery or replay overlap.
}
if event.Sequence != cursor.Sequence+1 {
return ErrGap
}
cursor.Sequence = event.Sequence
return nil
}
Persist the cursor only after applying the corresponding event to the local view. If the client sees a gap, pause live application, request events after its last contiguous sequence, and replay them in order before resuming the stream. During that handoff, buffer later live events or open the stream first and deduplicate replay overlap by sequence; otherwise an event committed between the history query and subscription can disappear from the view. Consider a student whose cursor is 41 while the worker has committed 42 and 43: if the client queries history through 42, then subscribes after 43 was broadcast, neither the replay nor the live stream contains 43. Opening the stream first and holding its events during replay removes that specific handoff window, provided duplicates are collapsed by sequence. Reconnection needs a bounded retention policy too: if the cursor predates available history, return an authoritative snapshot plus its sequence, then resume from there. A snapshot without its sequence cannot establish a clean handoff.
Replay can get expensive.
Which guarantee is worth carrying on call?
The table is a capacity-planning question as much as a correctness question. A larger room multiplies simultaneous connections and replay demand, while a slow client can retain an unbounded send queue unless the server caps it and forces a clean reconnect.
| Choice | What it establishes | Operational cost |
|---|---|---|
| Fire-and-forget fan-out | Best-effort live display | Small state footprint; disconnected learners miss events. |
| Persisted room log with replay | At-least-once delivery opportunity while events remain retained | Storage, replay reads, retention policy, and duplicate handling. |
| Client acknowledgment of contiguous sequence | Evidence of application progress per client | Ack storage and extra traffic; acks can also be lost. |
| Exactly-once display effect | One visible application of each room sequence | Requires idempotent client state transitions; transport alone cannot provide it. |
I would choose a retained log and idempotent application for accepted bids because a duplicated chat bubble is repairable, but a missing high bid changes the apparent outcome. Ordinary typing indicators warrant a different choice: replaying stale presence is more misleading than dropping it. Keep these message classes separate even if they share a connection.
The limitation of a retained log is that it cannot replay forever without a retention and storage budget, and it cannot establish what a client actually displayed unless that client acknowledges application. For a course that does not need recoverable bid history, best-effort broadcasts avoid much of that persistent-state burden; for an auction where the displayed winner matters, that trade-off is unacceptable. Choose the guarantee by event class, not by transport branding.
The buy-versus-build decision follows the same boundary. A managed transport can reduce connection operations, but the team still owns acceptance rules, sequence assignment, retention requirements, and the definition of a caught-up room; self-hosting the transport adds capacity limits, deployment drains, and on-call work. Neither option turns an acknowledgment of network delivery into proof that a student rendered the accepted bid. Test both with a forced disconnect after commit, a duplicate outbox dispatch, two concurrent higher bids, and a reconnect during replay. Track rejected bids separately from accepted-event delivery latency so a legitimate rejection does not appear as lost fan-out.
When does the alert do more harm than good?
Do not page on every reconnect or each transient replay gap. Wireless classroom networks make both common, and a gap that closes within the delivery SLO has not yet violated the user-facing objective. Alert on sustained oldest-undelivered age and on rooms that remain behind after recovery; annotate those signals with room size, replay rate, and outbox backlog so on-call can distinguish a single slow client from a worker-wide stall.
The cost of an aggressive threshold is real: noisy pages train the team to disregard the one backlog that will leave an instructor and students looking at different accepted bids. The cost of a permissive one is a silent, durable disagreement. Set the threshold from observed delivery latency and the classroom's recovery target, then exercise it during controlled worker restarts and client disconnects. The alert should tell on-call which committed sequence is waiting, not merely that a socket closed.
Further reading
- WebSocket Protocol, RFC 6455: https://www.rfc-editor.org/rfc/rfc6455
- Transactional Outbox pattern and duplicate delivery: https://microservices.io/patterns/data/transactional-outbox.html
- WebRTC 1.0 specification, for the separate peer-connection transport boundary: https://www.w3.org/TR/webrtc/
Top comments (0)