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.
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 ithub.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.
const token = getAccessToken() // your stored JWTconst 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')breakcase '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.
type Event struct {Type string `json:"type"`Payload interface{} `json:"payload"`}// on the wire: { "type": "notification.new", "payload": { ... } }
| Topic convention | Use |
|---|---|
| system.connected | Server greeting on first connect |
| notification.new | A new notification for the user |
| chat.message.new | A 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:
| Method | Reaches |
|---|---|
| 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.
type ExportWorker struct {DB *gorm.DBHub *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 failedw.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 connectedw.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 →