Six Months to Production: Building a Real-Time Collaboration Tool
Architecture

Six Months to Production: Building a Real-Time Collaboration Tool

November 25, 2024
14 min read

Six Months to Production: Building a Real-Time Collaboration Tool

Long projects expose the gaps between knowing a technology and understanding it. This is the guide for building something that survives contact with real users.

Six months is enough time to build something genuinely complex. It's also enough time to make every category of mistake — architecture mistakes in month one that you pay for in month five, performance mistakes you only discover under real load, and reliability mistakes that wake you up at 2 AM. This guide is about building a real-time document collaboration tool, the kind of architectural problems you will encounter, and how to think through them.

We're building a system where multiple users can edit a shared document simultaneously, see each other's cursors, and have changes propagate within milliseconds. Think Notion, but one you built and understand end to end.


Month 1: Architecture Decisions That Will Define Everything

The Real-Time Layer: Phoenix Channels vs. Node WebSockets

The most consequential early decision is the real-time transport. Two serious options exist:

Node.js with ws or Socket.io — stays in the JavaScript ecosystem, easy to find help, integrates naturally with a Next.js backend.

Phoenix/Elixir with Phoenix Channels — built for this exact use case. The BEAM VM can handle millions of concurrent connections on modest hardware. Phoenix Channels give you rooms (channels), presence tracking, and fault-tolerant process isolation out of the box.

For a project where real-time correctness matters more than ecosystem familiarity, Phoenix wins. Here's what a Phoenix Channel looks like:

elixir
1# lib/collab_web/channels/document_channel.ex
2defmodule CollabWeb.DocumentChannel do
3 use CollabWeb, :channel
4 alias Collab.Documents
5 alias Collab.Presence
6
7 def join("document:" <> doc_id, _payload, socket) do
8 with {:ok, document} <- Documents.get_document(doc_id, socket.assigns.user_id) do
9 send(self(), :after_join)
10 {:ok, %{content: document.content, version: document.version},
11 assign(socket, :doc_id, doc_id)}
12 else
13 _ -> {:error, %{reason: "unauthorized"}}
14 end
15 end
16
17 def handle_info(:after_join, socket) do
18 # Track presence so all clients know who else is in the document
19 {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
20 name: socket.assigns.user_name,
21 cursor: nil,
22 online_at: System.system_time(:millisecond)
23 })
24
25 push(socket, "presence_state", Presence.list(socket))
26 {:noreply, socket}
27 end
28
29 def handle_in("apply_operation", %{"op" => op, "version" => version}, socket) do
30 case Documents.apply_operation(socket.assigns.doc_id, op, version, socket.assigns.user_id) do
31 {:ok, new_version} ->
32 # Broadcast the operation to everyone else in the channel
33 broadcast_from!(socket, "operation", %{op: op, version: new_version,
34 author: socket.assigns.user_id})
35 {:reply, {:ok, %{version: new_version}}, socket}
36
37 {:error, :version_conflict} ->
38 {:reply, {:error, %{reason: "version_conflict"}}, socket}
39 end
40 end
41
42 def handle_in("cursor_move", %{"position" => position}, socket) do
43 Presence.update(socket, socket.assigns.user_id, fn meta ->
44 Map.put(meta, :cursor, position)
45 end)
46 {:noreply, socket}
47 end
48end

The Presence module is one of the most valuable parts of Phoenix. It uses CRDT-based distributed state to track which users are in which channel, handling node failures and network partitions automatically. Building this yourself in Node would take weeks.

Operational Transformation vs. CRDTs

This is the hardest technical decision in the project. When two users edit the same document simultaneously, whose change wins?

Operational Transformation (OT) — the approach Google Docs uses. Operations are transformed against concurrent operations to produce a consistent result. Complex to implement correctly but well-understood.

CRDTs (Conflict-free Replicated Data Types) — mathematical data structures that can always be merged without conflicts. Simpler to reason about but can produce surprising results for rich text.

For a first real-time project, use Yjs — a production-grade CRDT library that handles the hard math for you. It works in both the browser and Node/Deno, and has first-class Phoenix integration.

typescript
1// Client-side: connect Yjs to Phoenix Channel
2import * as Y from 'yjs'
3import { Socket } from 'phoenix'
4
5export function createCollabDoc(documentId: string, token: string) {
6 const ydoc = new Y.Doc()
7 const socket = new Socket('/socket', { params: { token } })
8 socket.connect()
9
10 const channel = socket.channel(`document:${documentId}`, {})
11
12 // Yjs update -> send to Phoenix
13 ydoc.on('update', (update: Uint8Array, origin: unknown) => {
14 if (origin !== 'remote') {
15 // Only broadcast local changes, not ones we received from the server
16 channel.push('apply_operation', {
17 update: Array.from(update), // Uint8Array doesn't serialize over JSON
18 version: ydoc.store.getState(),
19 })
20 }
21 })
22
23 // Phoenix -> apply Yjs update
24 channel.on('operation', ({ update }: { update: number[] }) => {
25 Y.applyUpdate(ydoc, new Uint8Array(update), 'remote')
26 })
27
28 channel.join()
29 .receive('ok', ({ content }: { content: number[] }) => {
30 if (content.length > 0) {
31 Y.applyUpdate(ydoc, new Uint8Array(content), 'remote')
32 }
33 })
34
35 return { ydoc, channel, socket }
36}

Month 2–3: Database Design for Multi-Tenant Apps

Multi-tenancy means multiple organisations share your infrastructure but must be completely isolated from each other. Get this wrong and you have a catastrophic data breach.

Tenant Isolation Strategies

Row-level security (RLS) in Postgres is the right answer for most applications. Every table gets a tenant_id column and Postgres enforces isolation at the database level — even if your application code has a bug, the database will not return rows from another tenant.

sql
1-- Enable RLS on the documents table
2ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
3
4-- Create a policy: users can only see documents in their organisation
5CREATE POLICY tenant_isolation ON documents
6 USING (
7 tenant_id = current_setting('app.current_tenant_id')::uuid
8 );
9
10-- Set the tenant context at the start of every transaction
11-- This is done in your connection middleware, not per-query
12SET LOCAL app.current_tenant_id = '${tenantId}';

In your Elixir/Ecto code, wrap every database interaction in a transaction that sets the tenant context:

elixir
1# lib/collab/repo.ex
2defmodule Collab.Repo do
3 use Ecto.Repo, otp_app: :collab, adapter: Ecto.Adapters.Postgres
4
5 def with_tenant(tenant_id, fun) do
6 transaction(fn ->
7 query!("SET LOCAL app.current_tenant_id = $1", [tenant_id])
8 fun.()
9 end)
10 end
11end
12
13# Usage in a context function:
14def get_document(doc_id, tenant_id) do
15 Repo.with_tenant(tenant_id, fn ->
16 Repo.get(Document, doc_id)
17 end)
18end

Schema Design for Documents

sql
1-- The core schema for a multi-tenant document system
2CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- for gen_random_uuid()
3
4CREATE TABLE tenants (
5 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6 name TEXT NOT NULL,
7 slug TEXT NOT NULL UNIQUE,
8 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
9);
10
11CREATE TABLE documents (
12 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
13 tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
14 title TEXT NOT NULL DEFAULT 'Untitled',
15 -- Store Yjs document state as binary blob, not as text
16 -- Text representation is reconstructed client-side
17 yjs_state BYTEA,
18 version BIGINT NOT NULL DEFAULT 0,
19 created_by UUID NOT NULL REFERENCES users(id),
20 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
21 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
22);
23
24CREATE INDEX documents_tenant_idx ON documents(tenant_id);
25
26-- Operation log for audit trail and conflict resolution
27CREATE TABLE document_operations (
28 id BIGSERIAL PRIMARY KEY,
29 document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
30 tenant_id UUID NOT NULL,
31 user_id UUID NOT NULL REFERENCES users(id),
32 operation BYTEA NOT NULL, -- serialised Yjs update
33 version BIGINT NOT NULL,
34 applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
35);
36
37CREATE INDEX doc_ops_document_idx ON document_operations(document_id, version);

Month 4: Redis Pub/Sub for Horizontal Scaling

Phoenix Channels work across a cluster out of the box using the BEAM's distributed Erlang. But if your frontend is served from Next.js and you have a separate Phoenix service, you need a message bus between them. Redis pub/sub is the standard answer.

typescript
1// server/pubsub.ts — Node service that bridges Redis and WebSockets
2import { createClient } from 'redis'
3import { WebSocketServer } from 'ws'
4
5const publisher = createClient({ url: process.env.REDIS_URL })
6const subscriber = createClient({ url: process.env.REDIS_URL })
7
8await publisher.connect()
9await subscriber.connect()
10
11// When Phoenix broadcasts an operation, it publishes to Redis
12// This service subscribes and forwards to connected browser clients
13export async function subscribeToDocument(
14 documentId: string,
15 onOperation: (op: Buffer) => void
16) {
17 await subscriber.subscribe(`doc:${documentId}:ops`, (message) => {
18 onOperation(Buffer.from(message, 'base64'))
19 })
20}
21
22export async function publishOperation(
23 documentId: string,
24 operation: Buffer
25) {
26 await publisher.publish(
27 `doc:${documentId}:ops`,
28 operation.toString('base64')
29 )
30}
31
32// Presence events get their own channel
33export async function publishPresence(
34 documentId: string,
35 userId: string,
36 cursor: { index: number } | null
37) {
38 await publisher.publish(
39 `doc:${documentId}:presence`,
40 JSON.stringify({ userId, cursor, timestamp: Date.now() })
41 )
42}

Key Redis patterns for real-time systems:

  • Use SUBSCRIBE not polling — pub/sub pushes events, polling burns CPU and adds latency
  • Expiring keys for presence — use SET user:online:${userId} 1 EX 30 and refresh it on every heartbeat. No separate cleanup job needed
  • Separate channels per document — don't put all documents on one channel; it creates unnecessary fan-out

Month 5: CI/CD Pipeline

By month five, manual deploys are costing you time and introducing errors. Set up a proper pipeline.

yaml
1# .github/workflows/deploy.yml
2name: Deploy
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 test:
10 runs-on: ubuntu-latest
11 services:
12 postgres:
13 image: postgres:16
14 env:
15 POSTGRES_PASSWORD: postgres
16 POSTGRES_DB: collab_test
17 options: >-
18 --health-cmd pg_isready
19 --health-interval 10s
20 --health-timeout 5s
21 --health-retries 5
22 redis:
23 image: redis:7
24 options: >-
25 --health-cmd "redis-cli ping"
26 --health-interval 10s
27
28 steps:
29 - uses: actions/checkout@v4
30
31 - name: Set up Elixir
32 uses: erlef/setup-beam@v1
33 with:
34 elixir-version: '1.16'
35 otp-version: '26'
36
37 - name: Cache deps
38 uses: actions/cache@v4
39 with:
40 path: deps
41 key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
42
43 - name: Install deps
44 run: mix deps.get
45
46 - name: Run Elixir tests
47 run: mix test
48 env:
49 DATABASE_URL: postgresql://postgres:postgres@localhost/collab_test
50 REDIS_URL: redis://localhost:6379
51
52 - name: Set up Node
53 uses: actions/setup-node@v4
54 with:
55 node-version: '20'
56 cache: 'npm'
57
58 - name: Install Node deps
59 run: npm ci
60
61 - name: Type check
62 run: npx tsc --noEmit
63
64 - name: Run Next.js tests
65 run: npm test
66
67 deploy:
68 needs: test
69 runs-on: ubuntu-latest
70 if: github.ref == 'refs/heads/main'
71 steps:
72 - uses: actions/checkout@v4
73
74 - name: Deploy Phoenix to Fly.io
75 uses: superfly/flyctl-actions/setup-flyctl@master
76 - run: flyctl deploy --remote-only
77 env:
78 FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
79
80 - name: Deploy Next.js to Vercel
81 run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

Deploy Phoenix to Fly.io, not a serverless platform. Long-lived WebSocket connections are incompatible with serverless — Fly.io runs persistent VMs that are still affordable and globally distributed.


Month 6: Monitoring and the Production Mindset

Code complete is not production ready. The gap between the two is what separates senior engineers from everyone else.

Structured Logging

Every request that touches your system should emit a structured log line:

typescript
1// lib/logger.ts
2type LogLevel = 'debug' | 'info' | 'warn' | 'error'
3
4interface LogEntry {
5 level: LogLevel
6 message: string
7 timestamp: string
8 traceId?: string
9 userId?: string
10 tenantId?: string
11 durationMs?: number
12 [key: string]: unknown
13}
14
15export function log(level: LogLevel, message: string, context: Partial<LogEntry> = {}) {
16 const entry: LogEntry = {
17 level,
18 message,
19 timestamp: new Date().toISOString(),
20 ...context,
21 }
22 // In production, ship this to Datadog / Logtail / Axiom as JSON
23 // In development, pretty-print it
24 if (process.env.NODE_ENV === 'production') {
25 process.stdout.write(JSON.stringify(entry) + '\n')
26 } else {
27 const color = { debug: '\x1b[90m', info: '\x1b[36m', warn: '\x1b[33m', error: '\x1b[31m' }[level]
28 console.log(`${color}[${level.toUpperCase()}]\x1b[0m ${message}`, context)
29 }
30}

Health Checks

Expose a /health endpoint that checks every dependency:

typescript
1// app/api/health/route.ts
2import { NextResponse } from 'next/server'
3import { prisma } from '@/lib/prisma'
4import { redis } from '@/lib/redis'
5
6export async function GET() {
7 const checks = await Promise.allSettled([
8 prisma.$queryRaw`SELECT 1`,
9 redis.ping(),
10 ])
11
12 const [db, cache] = checks
13 const healthy = checks.every(c => c.status === 'fulfilled')
14
15 return NextResponse.json(
16 {
17 status: healthy ? 'ok' : 'degraded',
18 checks: {
19 database: db.status === 'fulfilled' ? 'ok' : 'error',
20 cache: cache.status === 'fulfilled' ? 'ok' : 'error',
21 },
22 version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown',
23 },
24 { status: healthy ? 200 : 503 }
25 )
26}

Return 503 (not 200) when degraded. Load balancers and uptime monitors check the status code, not the body.


The Mindset Shift: From Code Complete to Production Ready

After six months you'll understand something that can't be taught from tutorials: the code is only about 40% of shipping a production system.

The rest is:

  • Observability — can you debug a problem at 2 AM from log output alone?
  • Graceful degradation — if Redis goes down, does the app stop working, or does it fall back gracefully?
  • Database migrations — can you deploy schema changes without downtime?
  • Rate limiting and abuse prevention — what happens when someone writes a script that creates 10,000 documents?
  • Backup and recovery — have you tested restoring from a backup? Not written the backup job — tested the restore?
  • Capacity planning — do you know what will break first when traffic doubles?

None of these questions have to do with writing code. They're all about operating a system. That's the gap this project closes.

Six months of building one real thing teaches you more than six years of building toy demos. The complexity is the point.

Tags
ArchitectureElixirPhoenixWebSocketsRedisCRDTsTypeScriptMulti-TenantDevOps
Kevin Mbugua Kiarie

Kevin Mbugua Kiarie

Senior Fullstack Developer

Five years building full-stack systems in TypeScript, Elixir, and Python. Writing about engineering, architecture, and the craft of shipping software that lasts.

Six Months to Production: Building a Real-Time Collaboration Tool · Kevin Kiarie