Real-Time Updates
Live data synchronization using Postgres LISTEN/NOTIFY, Go WebSocket broadcast, and TanStack Query cache invalidation. When any user creates, updates, or deletes a project (or its stages/members), all connected clients in the same org see the change immediately without polling.
Architecture
PostgreSQL trigger → pg_notify('project_changes', payload)
↓
Go WebSocket Hub ← LISTEN project_changes
↓
Hub broadcasts to all clients in the org room
↓
Browser WebSocket client receives message
↓
TanStack Query → queryClient.invalidateQueries()
↓
UI re-fetches stale data automaticallyDatabase Triggers
Trigger functions fire NOTIFY on the org_changes channel:
| Trigger | Table | Events | Channel |
|---|---|---|---|
trg_notify_project_change | projects | INSERT, UPDATE, DELETE | org_changes |
trg_notify_project_member_change | project_members | INSERT, UPDATE, DELETE | org_changes |
trg_notify_project_stage_change | project_stages | INSERT only (append-only) | org_changes |
trg_notify_custom_field_change | custom_field_definitions | INSERT, UPDATE, DELETE | org_changes |
Payload Format
{
"table": "projects",
"action": "UPDATE",
"org_id": "6d7ad774-...",
"project_id": "a1b2c3d4-..."
}The payload uses COALESCE(NEW.org_id, OLD.org_id) to handle both INSERT/UPDATE (NEW) and DELETE (OLD) operations.
Go WebSocket Hub
The hub manages connections organized by org rooms.
Components
| Component | File | Responsibility |
|---|---|---|
Hub | hub.go | Room management, registration, broadcast |
Client | client.go | Single WebSocket connection with read/write pumps |
ServeWS | handler.go | HTTP upgrade handler with auth validation |
Hub Lifecycle
- Hub created in
main.goand started withgo wsHub.ListenPostgres(ctx, pool) ListenPostgresacquires a pgx connection, runsLISTEN project_changes- On notification: unmarshals payload, broadcasts to all clients in
rooms[org_id] - On connection error: automatically reconnects after 5 seconds
Client Lifecycle
- Browser connects to
/v1/orgs/{orgId}/ws ServeWSvalidates BearerAuth JWT, upgrades to WebSocket- Client registered in hub’s org room
ReadPumpgoroutine: reads messages from client (mostly pong replies), enforces 512-byte max message sizeWritePumpgoroutine: forwards broadcast messages to client, sends ping every 54 seconds- On disconnect: client unregistered from room, connection closed
Connection Settings
| Setting | Value | Purpose |
|---|---|---|
| Write wait | 10s | Max time to write a message |
| Pong wait | 60s | Max time between pong replies |
| Ping period | 54s | Interval between ping frames |
| Max message size | 512B | Prevents abuse from oversized messages |
| Send buffer | 256 | Buffered channel size per client |
Frontend WebSocket Hook
useProjectWebSocket(orgId) — a React hook that manages the WebSocket lifecycle and TanStack Query cache invalidation.
Connection
Connects to {NEXT_PUBLIC_API_URL}/v1/orgs/{orgId}/ws. The hook uses the Go API URL directly (not via the BFF) — this is the documented exception to the BFF-only rule, since WebSocket connections cannot be proxied through Server Actions.
Cache Invalidation Strategy
On receiving a message, the hook invalidates specific query keys based on the payload:
Payload table | Invalidated Query Keys |
|---|---|
projects | ["projects", orgId], ["project", orgId, projectId] |
project_stages | ["stages", orgId, projectId] |
project_members | ["members", orgId, projectId], ["users", orgId], ["org-users-for-assign", orgId] |
business_units | ["business-units", orgId] |
users | ["users", orgId], ["org-users-for-assign", orgId] |
organizations | ["org-dashboard", orgId], ["org-profile", orgId] |
custom_field_definitions | ["org-custom-fields", orgId], ["project-custom-fields", orgId, projectId] |
When project_members changes, the ["users", orgId] key is also invalidated because the users table shows project_count per user. When custom_field_definitions changes, both org and project custom field queries are invalidated (project_id is included in the payload when the field belongs to a project).
Reconnection
If the WebSocket closes, the hook automatically reconnects after 3 seconds. Cleanup runs on component unmount (clears timeout and closes socket).
Endpoint
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /v1/orgs/{orgId}/ws | BearerAuth | WebSocket upgrade |
Security
- Authenticated connections — WebSocket upgrade requires a valid BearerAuth JWT. Unauthenticated requests are rejected before upgrade.
- Org-scoped rooms — Clients only receive broadcasts for their own org. The org_id is extracted from the JWT claims.
- Message size limit — 512-byte max prevents abuse.
- Ping/pong keepalive — Dead connections detected and cleaned up within 60 seconds.
- Graceful backpressure — If a client’s send buffer is full, the connection is closed rather than blocking the hub.
Key Files
| File | Purpose |
|---|---|
backend/db/migrations/003_projects.sql | LISTEN/NOTIFY trigger functions |
backend/api/internal/ws/hub.go | Hub: room management, Postgres listener, broadcast |
backend/api/internal/ws/client.go | Client: read/write pumps, ping/pong keepalive |
backend/api/internal/ws/handler.go | ServeWS: auth validation, HTTP upgrade |
apps/web/src/hooks/use-project-ws.ts | React hook: WebSocket + TanStack Query invalidation |