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:
elixir1# lib/collab_web/channels/document_channel.ex2defmodule CollabWeb.DocumentChannel do3 use CollabWeb, :channel4 alias Collab.Documents5 alias Collab.Presence67 def join("document:" <> doc_id, _payload, socket) do8 with {:ok, document} <- Documents.get_document(doc_id, socket.assigns.user_id) do9 send(self(), :after_join)10 {:ok, %{content: document.content, version: document.version},11 assign(socket, :doc_id, doc_id)}12 else13 _ -> {:error, %{reason: "unauthorized"}}14 end15 end1617 def handle_info(:after_join, socket) do18 # Track presence so all clients know who else is in the document19 {: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 })2425 push(socket, "presence_state", Presence.list(socket))26 {:noreply, socket}27 end2829 def handle_in("apply_operation", %{"op" => op, "version" => version}, socket) do30 case Documents.apply_operation(socket.assigns.doc_id, op, version, socket.assigns.user_id) do31 {:ok, new_version} ->32 # Broadcast the operation to everyone else in the channel33 broadcast_from!(socket, "operation", %{op: op, version: new_version,34 author: socket.assigns.user_id})35 {:reply, {:ok, %{version: new_version}}, socket}3637 {:error, :version_conflict} ->38 {:reply, {:error, %{reason: "version_conflict"}}, socket}39 end40 end4142 def handle_in("cursor_move", %{"position" => position}, socket) do43 Presence.update(socket, socket.assigns.user_id, fn meta ->44 Map.put(meta, :cursor, position)45 end)46 {:noreply, socket}47 end48end
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.
typescript1// Client-side: connect Yjs to Phoenix Channel2import * as Y from 'yjs'3import { Socket } from 'phoenix'45export function createCollabDoc(documentId: string, token: string) {6 const ydoc = new Y.Doc()7 const socket = new Socket('/socket', { params: { token } })8 socket.connect()910 const channel = socket.channel(`document:${documentId}`, {})1112 // Yjs update -> send to Phoenix13 ydoc.on('update', (update: Uint8Array, origin: unknown) => {14 if (origin !== 'remote') {15 // Only broadcast local changes, not ones we received from the server16 channel.push('apply_operation', {17 update: Array.from(update), // Uint8Array doesn't serialize over JSON18 version: ydoc.store.getState(),19 })20 }21 })2223 // Phoenix -> apply Yjs update24 channel.on('operation', ({ update }: { update: number[] }) => {25 Y.applyUpdate(ydoc, new Uint8Array(update), 'remote')26 })2728 channel.join()29 .receive('ok', ({ content }: { content: number[] }) => {30 if (content.length > 0) {31 Y.applyUpdate(ydoc, new Uint8Array(content), 'remote')32 }33 })3435 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.
sql1-- Enable RLS on the documents table2ALTER TABLE documents ENABLE ROW LEVEL SECURITY;34-- Create a policy: users can only see documents in their organisation5CREATE POLICY tenant_isolation ON documents6 USING (7 tenant_id = current_setting('app.current_tenant_id')::uuid8 );910-- Set the tenant context at the start of every transaction11-- This is done in your connection middleware, not per-query12SET LOCAL app.current_tenant_id = '${tenantId}';
In your Elixir/Ecto code, wrap every database interaction in a transaction that sets the tenant context:
elixir1# lib/collab/repo.ex2defmodule Collab.Repo do3 use Ecto.Repo, otp_app: :collab, adapter: Ecto.Adapters.Postgres45 def with_tenant(tenant_id, fun) do6 transaction(fn ->7 query!("SET LOCAL app.current_tenant_id = $1", [tenant_id])8 fun.()9 end)10 end11end1213# Usage in a context function:14def get_document(doc_id, tenant_id) do15 Repo.with_tenant(tenant_id, fn ->16 Repo.get(Document, doc_id)17 end)18end
Schema Design for Documents
sql1-- The core schema for a multi-tenant document system2CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- for gen_random_uuid()34CREATE 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);1011CREATE 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 text16 -- Text representation is reconstructed client-side17 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);2324CREATE INDEX documents_tenant_idx ON documents(tenant_id);2526-- Operation log for audit trail and conflict resolution27CREATE 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 update33 version BIGINT NOT NULL,34 applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()35);3637CREATE 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.
typescript1// server/pubsub.ts — Node service that bridges Redis and WebSockets2import { createClient } from 'redis'3import { WebSocketServer } from 'ws'45const publisher = createClient({ url: process.env.REDIS_URL })6const subscriber = createClient({ url: process.env.REDIS_URL })78await publisher.connect()9await subscriber.connect()1011// When Phoenix broadcasts an operation, it publishes to Redis12// This service subscribes and forwards to connected browser clients13export async function subscribeToDocument(14 documentId: string,15 onOperation: (op: Buffer) => void16) {17 await subscriber.subscribe(`doc:${documentId}:ops`, (message) => {18 onOperation(Buffer.from(message, 'base64'))19 })20}2122export async function publishOperation(23 documentId: string,24 operation: Buffer25) {26 await publisher.publish(27 `doc:${documentId}:ops`,28 operation.toString('base64')29 )30}3132// Presence events get their own channel33export async function publishPresence(34 documentId: string,35 userId: string,36 cursor: { index: number } | null37) {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
SUBSCRIBEnot polling — pub/sub pushes events, polling burns CPU and adds latency - Expiring keys for presence — use
SET user:online:${userId} 1 EX 30and 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.
yaml1# .github/workflows/deploy.yml2name: Deploy34on:5 push:6 branches: [main]78jobs:9 test:10 runs-on: ubuntu-latest11 services:12 postgres:13 image: postgres:1614 env:15 POSTGRES_PASSWORD: postgres16 POSTGRES_DB: collab_test17 options: >-18 --health-cmd pg_isready19 --health-interval 10s20 --health-timeout 5s21 --health-retries 522 redis:23 image: redis:724 options: >-25 --health-cmd "redis-cli ping"26 --health-interval 10s2728 steps:29 - uses: actions/checkout@v43031 - name: Set up Elixir32 uses: erlef/setup-beam@v133 with:34 elixir-version: '1.16'35 otp-version: '26'3637 - name: Cache deps38 uses: actions/cache@v439 with:40 path: deps41 key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}4243 - name: Install deps44 run: mix deps.get4546 - name: Run Elixir tests47 run: mix test48 env:49 DATABASE_URL: postgresql://postgres:postgres@localhost/collab_test50 REDIS_URL: redis://localhost:63795152 - name: Set up Node53 uses: actions/setup-node@v454 with:55 node-version: '20'56 cache: 'npm'5758 - name: Install Node deps59 run: npm ci6061 - name: Type check62 run: npx tsc --noEmit6364 - name: Run Next.js tests65 run: npm test6667 deploy:68 needs: test69 runs-on: ubuntu-latest70 if: github.ref == 'refs/heads/main'71 steps:72 - uses: actions/checkout@v47374 - name: Deploy Phoenix to Fly.io75 uses: superfly/flyctl-actions/setup-flyctl@master76 - run: flyctl deploy --remote-only77 env:78 FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}7980 - name: Deploy Next.js to Vercel81 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:
typescript1// lib/logger.ts2type LogLevel = 'debug' | 'info' | 'warn' | 'error'34interface LogEntry {5 level: LogLevel6 message: string7 timestamp: string8 traceId?: string9 userId?: string10 tenantId?: string11 durationMs?: number12 [key: string]: unknown13}1415export 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 JSON23 // In development, pretty-print it24 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:
typescript1// app/api/health/route.ts2import { NextResponse } from 'next/server'3import { prisma } from '@/lib/prisma'4import { redis } from '@/lib/redis'56export async function GET() {7 const checks = await Promise.allSettled([8 prisma.$queryRaw`SELECT 1`,9 redis.ping(),10 ])1112 const [db, cache] = checks13 const healthy = checks.every(c => c.status === 'fulfilled')1415 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.

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.