ModulesProjectsFeaturesReal-Time Updates

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 automatically

Database Triggers

Trigger functions fire NOTIFY on the org_changes channel:

TriggerTableEventsChannel
trg_notify_project_changeprojectsINSERT, UPDATE, DELETEorg_changes
trg_notify_project_member_changeproject_membersINSERT, UPDATE, DELETEorg_changes
trg_notify_project_stage_changeproject_stagesINSERT only (append-only)org_changes
trg_notify_custom_field_changecustom_field_definitionsINSERT, UPDATE, DELETEorg_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

ComponentFileResponsibility
Hubhub.goRoom management, registration, broadcast
Clientclient.goSingle WebSocket connection with read/write pumps
ServeWShandler.goHTTP upgrade handler with auth validation

Hub Lifecycle

  1. Hub created in main.go and started with go wsHub.ListenPostgres(ctx, pool)
  2. ListenPostgres acquires a pgx connection, runs LISTEN project_changes
  3. On notification: unmarshals payload, broadcasts to all clients in rooms[org_id]
  4. On connection error: automatically reconnects after 5 seconds

Client Lifecycle

  1. Browser connects to /v1/orgs/{orgId}/ws
  2. ServeWS validates BearerAuth JWT, upgrades to WebSocket
  3. Client registered in hub’s org room
  4. ReadPump goroutine: reads messages from client (mostly pong replies), enforces 512-byte max message size
  5. WritePump goroutine: forwards broadcast messages to client, sends ping every 54 seconds
  6. On disconnect: client unregistered from room, connection closed

Connection Settings

SettingValuePurpose
Write wait10sMax time to write a message
Pong wait60sMax time between pong replies
Ping period54sInterval between ping frames
Max message size512BPrevents abuse from oversized messages
Send buffer256Buffered 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 tableInvalidated 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

MethodPathAuthDescription
GET/v1/orgs/{orgId}/wsBearerAuthWebSocket 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

FilePurpose
backend/db/migrations/003_projects.sqlLISTEN/NOTIFY trigger functions
backend/api/internal/ws/hub.goHub: room management, Postgres listener, broadcast
backend/api/internal/ws/client.goClient: read/write pumps, ping/pong keepalive
backend/api/internal/ws/handler.goServeWS: auth validation, HTTP upgrade
apps/web/src/hooks/use-project-ws.tsReact hook: WebSocket + TanStack Query invalidation