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

  1. 01

    Identity

    Next.js obtains a Clerk session token; Clerk webhooks synchronize user profiles after Svix signature verification.

  2. 02

    History

    React Query loads message history through REST and validates the response at runtime with Zod.

  3. 03

    Connect

    The browser opens a WebSocket using the Clerk token as a subprotocol, and Go middleware resolves the identity.

  4. 04

    Persist

    readPump parses the message, then sqlc executes a CTE that stores it and updates the conversation's last-message reference.

  5. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

  1. 01

    Implement conversation membership authorization, trusted origins and server-derived sender IDs

  2. 02

    Refactor the frontend WebSocket provider around shared listeners, reconnect, backoff and recovery

  3. 03

    Add race tests, integration tests and failure scenarios for the Hub and message writes

  4. 04

    Define message idempotency keys, acknowledgement and offline compensation

  5. 05

    Add structured logs and connection, broadcast and database latency metrics before horizontal scaling