An order board becomes a security problem the moment a browser can choose what it watches. For an e-commerce kitchen display, publish a deliberately small projection after the authoritative order change, bind the connection to one store on the server, and treat the browser's location parameter as untrusted input. The same capability boundary applies when a checkout flow creates a video room: the issued token should name one room and a limited action set, rather than become a general-purpose credential because a client supplied a nearby identifier.
Short answer: split the system into an authoritative write lane and a display-only delivery lane. The write lane commits an order transition and records a durable event; the delivery lane gives a kiosk only its assigned store's display projection. A reconnect may restore that projection, but it must not broaden the kiosk's authority.
This is a small distinction with a large failure domain. A screen that goes stale is an operations problem. A screen that receives another store's customer details is a boundary failure.
How should a Node.js service publish kitchen order transitions to a read-only kiosk?
Start with a bounded production scenario before debating transports: a kiosk is carried away while still authenticated, reconnects within five minutes, and tries a different store identifier in its first message. The acceptable result is boring: it sees only the projection for the store embedded in its server-issued session. It cannot turn a reconnect into a store selector.
The invariant is specific enough to test: possession of a display credential permits observation of one store's display projection and no state mutation. The storage concern here is less about the socket than about what has been persisted. If the session record stores store_id, display_id, expiry, and a display-only role, every later delivery decision can be derived from server state. If the server instead accepts store_id from each client message, the access check is distributed across call sites and will eventually be skipped.
The browser should never receive the full order row merely because its UI renders three fields. A display event might contain an opaque order label, a named transition, an event ID, and the server-accepted timestamp. It does not need payment status, customer contact data, internal notes, or fulfillment controls.
The same rule survives a change in medium. WebRTC 1.0 defines browser APIs for real-time communication; it does not decide which video room a participant may join. Room membership and permitted actions remain an application authorization decision, just as store membership remains one for a kitchen display.
Publish a projection after the commit
The preventative path starts at the database boundary. Write the accepted transition and an outbox record in one transaction, then have a worker fan out the display event. That makes the event durable before delivery is attempted, which matters when a process dies after commit and before it reaches a connected screen. The worker may retry and publish an event more than once, so the display contract needs a stable event ID and idempotent application.
Here is the part reviewers should be able to point at: authorization happens when the subscription is established, and later fan-out uses the stored scope rather than a client-provided store field.
package display
import (
"context"
"errors"
"time"
)
var ErrForbidden = errors.New("display session may not receive events")
type Session struct {
DisplayID string
StoreID string
CanView bool
}
type Transition struct {
EventID string `json:"event_id"`
OrderView string `json:"order_view"`
State string `json:"state"`
ChangedAt time.Time `json:"changed_at"`
}
type SessionStore interface {
FindDisplaySession(ctx context.Context, sessionID string) (Session, error)
}
type Fanout interface {
PublishToStore(ctx context.Context, storeID string, event Transition) error
}
func PublishAcceptedTransition(
ctx context.Context,
sessions SessionStore,
fanout Fanout,
sessionID string,
event Transition,
) error {
session, err := sessions.FindDisplaySession(ctx, sessionID)
if err != nil {
return err
}
if !session.CanView {
return ErrForbidden
}
// StoreID comes from the server-side session, never from a browser message.
return fanout.PublishToStore(ctx, session.StoreID, event)
}
The function is intentionally narrow. In an implementation, the transaction that changes the order writes an outbox row; a separate consumer constructs Transition and calls the fan-out interface for authorized sessions or channels. The source of truth for the store is the committed order and the session, not the screen.
There is no universal freshness promise hiding in that pattern. Define one a kitchen can use, such as an accepted transition becoming visible on a connected authorized display within 2 seconds. Then measure commit-to-publish latency, outbox age, reconnect count, authorization denials, and the age of the last rendered event per display. An average across every store can look healthy while one busy location misses its window.
Two delivery lanes make failure behavior legible
The write lane accepts commands, validates the actor, applies the state transition, and appends an outbox record. The display lane reads only display projections, begins with a scoped snapshot, then receives newer events. A cursor or monotonically increasing sequence lets the server decide whether a display can catch up or needs a new snapshot; a timestamp from the browser cannot make that decision reliably.
Duplicates happen.
For a screen, a tiny receipt table keyed by event ID is often enough to make applying the same transition harmless. Retain those IDs for at least the reconnect and replay window that the product promises. Storage capacity follows from that chosen window: active displays multiplied by event rate multiplied by retention, plus headroom for bursts. This is capacity planning, not a transport feature.
| Approach | What the display receives | Operational burden | Boundary to verify |
|---|---|---|---|
| Scoped event stream | A store-specific projection and cursor | Connection lifecycle, replay, slow consumers | Server binds scope at subscription time |
| Filtered snapshot polling | The current projection at an explicit interval | Repeated reads and accepted staleness | Filter is enforced before data leaves the service |
| Direct record access | A broad domain object | Schema coupling and a much larger blast radius | The display has no accidental mutation or cross-store path |
WebSocket's protocol gives a bidirectional channel; Server-Sent Events provide a one-way stream over HTTP. Neither protocol supplies the authorization model, replay semantics, or SLO. A read-only kiosk normally benefits from a one-directional contract because its job is visible state, not a second administrative client. Use a bidirectional channel only when the display has a separately authorized need such as an explicit health signal.
How should a team choose the fan-out layer?
Choose from the recovery contract first, then from the on-call contract. The queue or log must preserve enough information to rebuild a display projection after a consumer or network failure, and the team must be prepared to operate its retention, backpressure, access controls, and upgrades. A managed transport can reduce runtime work while adding an external dependency and integration boundary. A self-operated transport keeps more control in-house while making patching and capacity review part of service ownership.
| Option | Useful property | Cost the team owns | Exit condition |
|---|---|---|---|
| Redis Streams | Consumer groups and explicit pending entries | Retention policy, memory pressure, recovery procedures | Replay contract is tested independently of the client library |
| NATS JetStream | Persisted streams with consumer delivery state | Stream sizing, replication choices, operator knowledge | Event schema remains portable outside the broker |
| Apache Kafka | Partitioned durable logs and consumer offsets | Cluster operations, partition planning, longer operational runway | Ordering assumptions are explicit per partition key |
Those are different operational shapes, not a ranking. For a modest board with a stated stale-data tolerance, filtered polling can be the most honest choice and may avoid a persistent connection fleet altogether. For high transition volume, isolate slow consumers so a stalled screen cannot delay the outbox worker or widen memory queues without a bound.
A persistent stream also has a clear limitation: it is a poor fit when the kitchen cannot support connection monitoring, replay testing, and bounded slow-consumer behavior. In that case, a filtered snapshot at a declared interval is the appropriate design because it turns freshness into an observable promise instead of an assumption. Direct record access is likewise a poor fit for a kiosk because it couples a display to the broader order schema and enlarges the authority that must be defended.
Video rooms follow the same decision rule: issue a short-lived, server-validated capability for a specific room and permitted media actions; do not let the client exchange a broad identity for a room name of its choosing. The mechanism differs, but the trust calculation does not.
Where this boundary is the wrong abstraction
A display projection is the wrong interface for a workflow that needs to acknowledge preparation, change an order, expose payment state, or search across stores. Give those actions their own command interface, actor authorization, audit trail, and failure handling. Folding them into the convenience channel turns a kiosk into a control plane.
It is also the wrong optimization when the system cannot state a recovery expectation. Before adding fan-out infrastructure, decide whether the kitchen needs a 2-second visible-state objective, a 30-second refresh interval, or something else the operation can observe. The smallest authority that meets that objective is the design to defend.
Sources
References:
- https://www.w3.org/TR/webrtc/
- https://www.rfc-editor.org/rfc/rfc6455
- https://html.spec.whatwg.org/multipage/server-sent-events.html
- https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- https://microservices.io/patterns/data/transactional-outbox.html
- https://redis.io/docs/latest/develop/data-types/streams/
- https://docs.nats.io/nats-concepts/jetstream
- https://kafka.apache.org/documentation/
Top comments (0)