Building a real-time bar ordering system with SSE
Why I chose Server-Sent Events over WebSockets to keep floor, bar and kitchen perfectly in sync — and how the whole architecture fits together.
When a customer orders from their table, three different screens need to react immediately: the customer’s own device, the bar, and the kitchen. If any of them lags behind, you get duplicated drinks, cold food, and frustrated staff. This is the story of how I built that system — and why I reached for Server-Sent Events (SSE) instead of the WebSockets everyone assumes you need.
The problem
A bar is a small, chaotic real-time network. Orders flow in one dominant direction — from the floor to the people preparing things — and every station needs a live view of what’s pending, in progress, and done. The hard part isn’t the UI; it’s keeping every screen agreeing on the same truth without manual refreshes.
Why SSE, not WebSockets
WebSockets are the default reflex for “real-time”, but they’re bidirectional and stateful, which is more than this problem needs. The data flow here is overwhelmingly server → client: the backend pushes order events, and clients send the occasional action over a plain HTTP request.
SSE gave me three concrete wins:
- It’s just HTTP. It works through proxies, load balancers and the existing auth middleware with zero special handling.
- Automatic reconnection is built in. The browser/native
EventSourcereconnects on its own and can resume from the last event id. - Backpressure is simpler. One long-lived response stream per client, no connection upgrade dance.
For the rare client→server messages (mark a ticket as ready, claim an order), a normal POST is more than enough.
The architecture
The backend is written in Go, which is a great fit: goroutines and channels map naturally onto “fan out one event to N subscribers”.
// A hub fans order events out to every connected station.
type Hub struct {
mu sync.RWMutex
subscribers map[string]chan Event
}
func (h *Hub) Broadcast(e Event) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, ch := range h.subscribers {
select {
case ch <- e:
default: // never block the broadcaster on a slow client
}
}
}
Each station opens an SSE stream and gets its own buffered channel. The mobile and station apps are built in React Native, so the same codebase powers the customer app and the bar/kitchen displays, each subscribing to the event types it cares about.
Keeping screens in sync
Every order change is a single immutable event (order.created, item.started, item.ready). Clients apply events to a local store, so the UI is just a projection of the event stream. Because each event carries an id, a screen that reconnects sends Last-Event-ID and the server replays anything it missed — no full reload, no flicker.
Resilience
Bars have terrible Wi-Fi. The system assumes disconnections are normal, not exceptional:
- Events are buffered server-side per station for a short window.
- The client reconciles on reconnect using the last id it saw.
- Actions are idempotent, so a retried “mark ready” never double-applies.
Takeaways
Reaching for the simplest tool that fits the data flow paid off. SSE removed a whole category of connection-management bugs, rode on infrastructure I already had, and kept the mental model tiny: the server owns the truth, and every screen is just a live view of it.
If your real-time problem is mostly one-directional, try SSE before WebSockets. You may find you never need the upgrade.