CASE STUDY / REALTIME SYSTEM
Chatify
Active refactor
From a BaaS prototype to a Go realtime messaging path
A Go, PostgreSQL and WebSocket backend added around Chatify's query-cost and N+1 problems while preserving the Next.js and Convex capabilities still needed during migration.
01 / PROBLEM
Why rebuild the backend?
The original implementation kept most conversation and message data in Convex. As relational reads grew, the lack of SQL joins turned a page load into many queries. The goal is not a language swap: it is to establish an explicit model for conversations, members, messages and unread state, then persist each message before realtime delivery.
- Represent conversations, members, friendships and messages relationally
- Persist messages before broadcasting them to active connections
- Connect native SQL to Go's type system through generated code
- Retain Clerk identity and LiveKit media capabilities
02 / MESSAGE FLOW
How a message is persisted and delivered
- 01
Identity
Next.js obtains a Clerk session token; Clerk webhooks synchronize user profiles after Svix signature verification.
- 02
History
React Query loads message history through REST and validates the response at runtime with Zod.
- 03
Connect
The browser opens a WebSocket using the Clerk token as a subprotocol, and Go middleware resolves the identity.
- 04
Persist
readPump parses the message, then sqlc executes a CTE that stores it and updates the conversation's last-message reference.
- 05
Broadcast
The Hub groups clients by conversation ID and each connection's writePump delivers outbound messages.
03 / ENGINEERING DECISIONS
Decisions visible in the code
- 01
Replace repeated assembly with a relational model
ImplementationPostgreSQL models users, conversations, members, messages and friends in separate tables. CTEs and joins return members, the latest message and unread counts together.
ValueImplicit data dependencies become database constraints and reviewable SQL, giving N+1, indexing and consistency work a concrete entry point.
- 02
Keep SQL as the contract and generate Go types
ImplementationQueries live in a dedicated directory; sqlc generates parameter and result types, while ordered golang-migrate migrations record schema changes.
ValueThis retains control over SQL while reducing handwritten scans, field-order mistakes and model/query drift.
- 03
Persist successfully before realtime broadcast
ImplementationAfter readPump receives a message, it runs CreateMessage, loads the complete row with sender data, and only then writes to the Hub broadcast channel.
ValueClients receive a message that already has a database ID and timestamp, preventing ghost messages caused by broadcasting before a failed write.
- 04
Isolate connections and broadcast scope by conversation
ImplementationThe Hub maps conversation IDs to client sets. Each client owns an independent buffered send channel and read/write goroutines.
ValueBroadcasting only scans the relevant conversation, and a dedicated writer keeps one network operation from directly blocking reads.
- 05
Decouple media from text messaging
ImplementationThe custom WebSocket and PostgreSQL path owns text. LiveKit owns audio/video, while a non-cacheable Next.js handler only signs short-lived room tokens.
ValueThe application controls its data path without rebuilding WebRTC media infrastructure inside the business service.
- 06
Separate server state from form state
ImplementationReact Query manages message history, Zod validates REST responses and React Hook Form manages input. WebSocket events update the targeted conversation cache.
ValueThis reduces coupling among request state, message lists and input, leaving clearer boundaries for reconnection and optimistic updates.
04 / CURRENT BOUNDARIES
What is not production-ready yet
These risks are verifiable in the current code and should be addressed before the next production-oriented iteration.
- High priority
Conversation authorization is incomplete
The handshake validates Clerk identity, but hasAccessToConversation currently returns true and sender ID comes from the client. The server must map the token to a database user and query conversation_members before upgrading.
- High priority
The WebSocket trust boundary is too broad
CheckOrigin currently accepts every origin. Trusted origins must be configured and sender identity must be derived server-side.
- Medium priority
Connection lifecycle and delivery semantics are incomplete
There is no ping/pong, deadline, automatic reconnect, acknowledgement or idempotency key. The frontend may also replace an existing onmessage handler when sending.
- Medium priority
The Hub is single-instance only
Connections and broadcast channels live in process memory. Horizontal scaling needs Redis Pub/Sub, NATS or another cross-node channel plus explicit ordering and retry semantics.
- Medium priority
Concurrency safeguards and observability are thin
The broadcast path removes clients while holding a read lock and database writes use context.Background. Metrics for connections, delivery failures and latency are also missing.
05 / NEXT ITERATION
What to improve next
- 01
Implement conversation membership authorization, trusted origins and server-derived sender IDs
- 02
Refactor the frontend WebSocket provider around shared listeners, reconnect, backoff and recovery
- 03
Add race tests, integration tests and failure scenarios for the Hub and message writes
- 04
Define message idempotency keys, acknowledgement and offline compensation
- 05
Add structured logs and connection, broadcast and database latency metrics before horizontal scaling