Backend

Realtime (WebSockets)

Push live updates to the browser — a toast when a background job finishes, a new-message badge, a "someone edited this" notice. Grit's realtime package is a tiny WebSocket fan-out Hub: clients connect once at GET /api/ws, and any handler, service, or worker can call SendToUser or Broadcast from anywhere in the app.

How it fits together

One Hub per process owns a registry keyed by user id. A single user can hold several connections at once (desktop, mobile, web) — each is a Client with its own buffered send channel. The hub never blocks: a slow client's message is dropped for that connection only, and it resyncs on its next REST refetch.

SourceHub (1 / process)Connections (per user)publishbroadcastEvente.g. flag.updatedHubregistry by user idWebsend channelMobilesend channelDesktopsend channel
HubOne connection per device
One Hub fans an event to every connection a user holds; a slow client is dropped, never blocking
internal/realtime
GET /api/ws?token=<jwt>
│ validate JWT → claims.UserID
upgrader.Upgrade(...) → *websocket.Conn
Hub.Register(client) clients: map[userID]→ set of *Client
│ user "u1" → { desktop, mobile }
├─ writePump (hub → socket, + keepalive pings every 54s)
└─ readPump (socket → hub; services ping/pong, cleans up on close)
── anywhere in app code ───────────────────────────────
hub.SendToUser("u1", Event{Type: "job.finished", Payload: …})
→ every connection bound to u1 receives it
hub.Broadcast(Event{Type: "system.maintenance", Payload: …})
→ every connected client, all users

Connecting: GET /api/ws

The one endpoint upgrades an HTTP request to a WebSocket. Auth is a query-string JWT (?token=…) — browsers can't set an Authorization header on a WebSocket handshake, so the token rides the query string and is validated with the same AuthService.ValidateToken as the REST API. On success the server registers the client and sends a system.connected greeting so the client knows the link is live.

apps/web — connecting from the client
const token = getAccessToken() // your stored JWT
const ws = new WebSocket(`${API_WS_URL}/api/ws?token=${token}`)
ws.onmessage = (e) => {
const evt = JSON.parse(e.data) as { type: string; payload: unknown }
switch (evt.type) {
case 'system.connected':
console.log('realtime link live')
break
case 'job.finished':
toast.success('Your export is ready')
queryClient.invalidateQueries({ queryKey: ['exports'] })
break
}
}

One-way by design. The readPump doesn't accept commands from clients — all mutations go through the authenticated REST API. It only services ping/pong keepalives (55s ping, 60s pong deadline) and cleans up when the socket closes. Think of the WebSocket as a push channel, not an RPC transport.

The event envelope

Every message on the wire is the same JSON envelope: a type topic string and an arbitrary payload. Topics are caller-defined; the package suggests dotted namespacing.

internal/realtime/hub.go
type Event struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// on the wire: { "type": "notification.new", "payload": { ... } }
Topic conventionUse
system.connectedServer greeting on first connect
notification.newA new notification for the user
chat.message.newA chat message payload
resource.<name>.<verb>e.g. building.created, lease.expired

SendToUser vs Broadcast

The hub is built once in routes.Setup (realtime.NewHub()) and shared. Hand it to any handler, service, or worker that needs to push. Three ways out:

MethodReaches
SendToUser(userID, evt)Every connection bound to one user (all their devices)
SendToUsers(ids, evt)A slice of users — fans out SendToUser per id
Broadcast(evt)Every connected client, all users — use sparingly

All three marshal the event once and push to each client's buffered Send channel with a non-blocking select — a full buffer drops the message for that connection rather than stalling the hub. User ids are UUID strings, matching User.ID.

Example: notify a user when a job finishes

A background worker generates an export, then pushes a job.finished event to just that user. Every device they have open lights up; the browser example above invalidates its React Query cache and shows a toast.

internal/jobs/export_worker.go
type ExportWorker struct {
DB *gorm.DB
Hub *realtime.Hub
}
func (w *ExportWorker) Handle(ctx context.Context, userID string) error {
url, err := w.generateExport(ctx, userID)
if err != nil {
// tell just this user it failed
w.Hub.SendToUser(userID, realtime.Event{
Type: "job.failed",
Payload: map[string]any{"kind": "export", "error": err.Error()},
})
return err
}
// push the ready file to every device this user has connected
w.Hub.SendToUser(userID, realtime.Event{
Type: "job.finished",
Payload: map[string]any{"kind": "export", "download_url": url},
})
return nil
}

A system-wide notice — maintenance window, a shipped feature flag change — uses Broadcast instead. In fact the feature-flags engine already does this: an admin flag write calls hub.Broadcast(Event{Type: "flag.updated"}) so connected clients can refetch.

Go deeper

Build a full realtime chat on top of the Hub — rooms, presence, typing indicators, and reconnection — wiring the WebSocket to React Query and optimistic updates.

Course: Realtime Chat with WebSockets →