As Software Engineering Lead at Yadn, I architected OLAB—a visual collaboration platform combining a Draw.io-style canvas with Notion-style databases. The challenge? Build real-time collaboration that feels instant, while keeping data perfectly synchronized across multiple views.
Here’s the technical architecture that made it work for thousands of concurrent users.
The Core Challenge
OLAB needed to support:
- Visual Canvas: Drag-and-drop nodes, connections, real-time updates
- Database Views: Table view, kanban, calendar, all synced with canvas
- Document Editor: Tiptap-based editor with embedded tables and canvases
- Real-time Sync: Changes propagate instantly across all views
- Conflict Resolution: Multiple users editing simultaneously
The Technology Stack
// Core Stack
- Next.js 14 (App Router)
- React Flow (Canvas)
- TanStack Table (Database)
- Tiptap (Editor)
- Supabase (Backend + Realtime)
- Tailwind CSS + shadcn/ui
- Zustand (State Management)
Architecture: The Canvas Layer
React Flow Foundation
// types/canvas.ts
interface CanvasNode {
id: string;
type: "database" | "note" | "file" | "image";
position: { x: number; y: number };
data: {
label: string;
content: any;
metadata: Record<string, any>;
};
}
interface CanvasEdge {
id: string;
source: string;
target: string;
type: "solid" | "dashed" | "arrow";
label?: string;
}
// components/Canvas/Canvas.tsx
import ReactFlow, {
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
Connection,
} from "reactflow";
export function Canvas({ canvasId }: { canvasId: string }) {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const { data: canvasData, channel } = useRealtimeCanvas(canvasId);
// Sync local state with Supabase
useEffect(() => {
if (canvasData) {
setNodes(canvasData.nodes);
setEdges(canvasData.edges);
}
}, [canvasData]);
// Handle real-time updates from other users
useEffect(() => {
if (!channel) return;
channel
.on("broadcast", { event: "node-update" }, ({ payload }) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === payload.id ? { ...node, ...payload.changes } : node
)
);
})
.on("broadcast", { event: "edge-update" }, ({ payload }) => {
setEdges((edges) =>
edges.map((edge) =>
edge.id === payload.id ? { ...edge, ...payload.changes } : edge
)
);
});
return () => {
channel.unsubscribe();
};
}, [channel]);
const onConnect = useCallback(
(connection: Connection) => {
const newEdge = {
...connection,
id: `edge-${Date.now()}`,
type: "default",
};
setEdges((edges) => addEdge(newEdge, edges));
// Broadcast to other users
channel?.send({
type: "broadcast",
event: "edge-create",
payload: newEdge,
});
// Persist to database
saveEdge(newEdge);
},
[channel]
);
return (
<div className="h-full w-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={customNodeTypes}
fitView
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
);
}
Custom Node Types
// components/Canvas/Nodes/DatabaseNode.tsx
import { Handle, Position } from "reactflow";
import { useStore } from "@/store/canvas";
interface DatabaseNodeProps {
id: string;
data: {
databaseId: string;
label: string;
recordCount: number;
};
}
export function DatabaseNode({ id, data }: DatabaseNodeProps) {
const updateNode = useStore((state) => state.updateNode);
const [isExpanded, setIsExpanded] = useState(false);
const { data: records } = useQuery({
queryKey: ["database", data.databaseId],
queryFn: () => fetchDatabaseRecords(data.databaseId),
enabled: isExpanded,
});
return (
<div className="bg-white rounded-lg shadow-lg border-2 border-gray-200 min-w-[200px]">
<Handle type="target" position={Position.Top} />
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{data.label}</h3>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-sm text-gray-500"
>
{isExpanded ? "−" : "+"}
</button>
</div>
<p className="text-sm text-gray-500">{data.recordCount} records</p>
{isExpanded && (
<div className="mt-3 space-y-1 max-h-48 overflow-y-auto">
{records?.map((record) => (
<div key={record.id} className="text-sm p-2 bg-gray-50 rounded">
{record.title}
</div>
))}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
// Register custom nodes
const customNodeTypes = {
database: DatabaseNode,
note: NoteNode,
file: FileNode,
image: ImageNode,
};
Challenge #1: Real-Time Synchronization
The Conflict Resolution Problem
Multiple users editing simultaneously can cause:
- Position conflicts (two users moving same node)
- Data conflicts (editing same field)
- Race conditions (delete + edit)
Solution: Operational Transformation Lite
// lib/realtime/sync-engine.ts
interface Operation {
id: string;
type: "node-move" | "node-update" | "edge-create" | "edge-delete";
timestamp: number;
userId: string;
data: any;
version: number;
}
class SyncEngine {
private pendingOps: Operation[] = [];
private appliedOps = new Set<string>();
async applyOperation(op: Operation) {
// Check if already applied (deduplication)
if (this.appliedOps.has(op.id)) {
return;
}
// Check version for conflicts
const currentVersion = await this.getCurrentVersion();
if (op.version !== currentVersion) {
// Conflict detected, rebase operation
const rebased = await this.rebaseOperation(op, currentVersion);
op = rebased;
}
// Apply operation based on type
switch (op.type) {
case "node-move":
await this.applyNodeMove(op);
break;
case "node-update":
await this.applyNodeUpdate(op);
break;
// ... other operations
}
this.appliedOps.add(op.id);
// Broadcast to other clients
await this.broadcastOperation(op);
}
private async rebaseOperation(
op: Operation,
targetVersion: number
): Promise<Operation> {
// Get all operations between op.version and targetVersion
const intermediateOps = await this.getOperationsBetween(
op.version,
targetVersion
);
// Transform operation against intermediate operations
let transformed = op;
for (const intermediateOp of intermediateOps) {
transformed = this.transform(transformed, intermediateOp);
}
return {
...transformed,
version: targetVersion,
};
}
private transform(op1: Operation, op2: Operation): Operation {
// If operations affect different nodes, no transformation needed
if (op1.data.nodeId !== op2.data.nodeId) {
return op1;
}
// Handle specific conflict scenarios
if (op1.type === "node-move" && op2.type === "node-move") {
// Last write wins, but preserve relative offset
return {
...op1,
data: {
...op1.data,
position: {
x:
op2.data.position.x + (op1.data.position.x - op2.data.position.x),
y:
op2.data.position.y + (op1.data.position.y - op2.data.position.y),
},
},
};
}
return op1;
}
}
Optimistic Updates with Rollback
// hooks/useOptimisticUpdate.ts
export function useOptimisticUpdate() {
const [history, setHistory] = useState<Operation[]>([]);
const applyOptimistic = useCallback(
async (operation: Operation, rollbackFn: () => void) => {
// Apply immediately (optimistic)
applyLocalOperation(operation);
try {
// Send to server
await sendOperation(operation);
// Clear from history on success
setHistory((h) => h.filter((op) => op.id !== operation.id));
} catch (error) {
// Rollback on failure
console.error("Operation failed, rolling back:", error);
rollbackFn();
// Show error to user
toast.error("Failed to save changes. Please try again.");
}
},
[]
);
return { applyOptimistic };
}
// Usage in component
const { applyOptimistic } = useOptimisticUpdate();
const handleNodeMove = (nodeId: string, newPosition: Position) => {
const oldPosition = nodes.find((n) => n.id === nodeId)?.position;
// Update UI immediately
setNodes((nodes) =>
nodes.map((n) => (n.id === nodeId ? { ...n, position: newPosition } : n))
);
// Send to server with rollback
applyOptimistic(
{
id: generateId(),
type: "node-move",
timestamp: Date.now(),
userId: currentUser.id,
data: { nodeId, position: newPosition },
version: currentVersion,
},
() => {
// Rollback function
setNodes((nodes) =>
nodes.map((n) =>
n.id === nodeId ? { ...n, position: oldPosition } : n
)
);
}
);
};
Challenge #2: Database Integration
Bi-directional Sync: Canvas ↔ Table View
// lib/database/sync.ts
class DatabaseCanvasSync {
private canvasId: string;
private databaseId: string;
constructor(canvasId: string, databaseId: string) {
this.canvasId = canvasId;
this.databaseId = databaseId;
this.setupSync();
}
private setupSync() {
// Listen to canvas changes
supabase
.channel(`canvas:${this.canvasId}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "canvas_nodes",
filter: `canvas_id=eq.${this.canvasId}`,
},
(payload) => {
this.handleCanvasChange(payload);
}
)
.subscribe();
// Listen to database changes
supabase
.channel(`database:${this.databaseId}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "database_records",
filter: `database_id=eq.${this.databaseId}`,
},
(payload) => {
this.handleDatabaseChange(payload);
}
)
.subscribe();
}
private async handleCanvasChange(payload: any) {
const { eventType, new: newRecord, old: oldRecord } = payload;
switch (eventType) {
case "INSERT":
// New node on canvas, create database record if it's a database node
if (newRecord.type === "database") {
await this.createDatabaseRecord(newRecord);
}
break;
case "UPDATE":
// Node moved or updated, sync to database
await this.updateDatabaseRecord(newRecord);
break;
case "DELETE":
// Node deleted, handle database record
await this.handleNodeDeletion(oldRecord);
break;
}
}
private async handleDatabaseChange(payload: any) {
const { eventType, new: newRecord, old: oldRecord } = payload;
switch (eventType) {
case "INSERT":
// New database record, check if should appear on canvas
await this.maybeCreateCanvasNode(newRecord);
break;
case "UPDATE":
// Database record updated, sync to canvas node
await this.updateCanvasNode(newRecord);
break;
case "DELETE":
// Database record deleted, update or remove canvas node
await this.handleRecordDeletion(oldRecord);
break;
}
}
}
TanStack Table with Real-time Updates
// components/Database/DatabaseTable.tsx
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
ColumnDef,
} from "@tanstack/react-table";
export function DatabaseTable({ databaseId }: { databaseId: string }) {
const [data, setData] = useState<DatabaseRecord[]>([]);
const [sorting, setSorting] = useState<SortingState>([]);
// Subscribe to real-time updates
useEffect(() => {
const channel = supabase
.channel(`database:${databaseId}`)
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "database_records",
filter: `database_id=eq.${databaseId}`,
},
(payload) => {
setData((current) => {
switch (payload.eventType) {
case "INSERT":
return [...current, payload.new];
case "UPDATE":
return current.map((record) =>
record.id === payload.new.id ? payload.new : record
);
case "DELETE":
return current.filter((record) => record.id !== payload.old.id);
default:
return current;
}
});
}
)
.subscribe();
return () => {
channel.unsubscribe();
};
}, [databaseId]);
// Inline editing
const handleCellEdit = useCallback(
async (rowId: string, columnId: string, value: any) => {
// Optimistic update
setData((current) =>
current.map((record) =>
record.id === rowId ? { ...record, [columnId]: value } : record
)
);
// Persist to database
try {
await supabase
.from("database_records")
.update({ [columnId]: value })
.eq("id", rowId);
} catch (error) {
// Rollback on error
toast.error("Failed to save changes");
// Re-fetch data
await refetch();
}
},
[]
);
const columns: ColumnDef<DatabaseRecord>[] = useMemo(
() => [
{
accessorKey: "title",
header: "Title",
cell: ({ getValue, row }) => {
const [value, setValue] = useState(getValue());
return (
<input
value={value as string}
onChange={(e) => setValue(e.target.value)}
onBlur={() => handleCellEdit(row.id, "title", value)}
className="w-full border-none bg-transparent"
/>
);
},
},
// ... more columns
],
[handleCellEdit]
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
},
onSortingChange: setSorting,
});
return <TableComponent table={table} />;
}
Challenge #3: Performance at Scale
Virtual Scrolling for Large Canvases
// components/Canvas/VirtualCanvas.tsx
import { useVirtualizer } from "@tanstack/react-virtual";
export function VirtualCanvas({ nodes }: { nodes: CanvasNode[] }) {
const parentRef = useRef<HTMLDivElement>(null);
// Group nodes into grid cells for efficient rendering
const gridSize = 1000; // pixels
const nodesByCell = useMemo(() => {
const cells = new Map<string, CanvasNode[]>();
nodes.forEach((node) => {
const cellX = Math.floor(node.position.x / gridSize);
const cellY = Math.floor(node.position.y / gridSize);
const cellKey = `${cellX},${cellY}`;
if (!cells.has(cellKey)) {
cells.set(cellKey, []);
}
cells.get(cellKey)!.push(node);
});
return cells;
}, [nodes]);
// Only render visible cells
const [viewport, setViewport] = useState({
x: 0,
y: 0,
width: 1920,
height: 1080,
});
const visibleCells = useMemo(() => {
const cells: string[] = [];
const startX = Math.floor(viewport.x / gridSize);
const endX = Math.ceil((viewport.x + viewport.width) / gridSize);
const startY = Math.floor(viewport.y / gridSize);
const endY = Math.ceil((viewport.y + viewport.height) / gridSize);
for (let x = startX; x <= endX; x++) {
for (let y = startY; y <= endY; y++) {
cells.push(`${x},${y}`);
}
}
return cells;
}, [viewport]);
const visibleNodes = useMemo(() => {
return visibleCells.flatMap((cellKey) => nodesByCell.get(cellKey) || []);
}, [visibleCells, nodesByCell]);
return (
<div
ref={parentRef}
onScroll={(e) => {
const target = e.target as HTMLDivElement;
setViewport({
x: target.scrollLeft,
y: target.scrollTop,
width: target.clientWidth,
height: target.clientHeight,
});
}}
>
<ReactFlow
nodes={visibleNodes}
// ... other props
/>
</div>
);
}
Debounced Auto-Save
// hooks/useAutoSave.ts
export function useAutoSave<T>(
data: T,
saveFn: (data: T) => Promise<void>,
delay = 1000
) {
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const debouncedSave = useMemo(
() =>
debounce(async (data: T) => {
setIsSaving(true);
try {
await saveFn(data);
setLastSaved(new Date());
} catch (error) {
console.error("Auto-save failed:", error);
toast.error("Failed to save changes");
} finally {
setIsSaving(false);
}
}, delay),
[saveFn, delay]
);
useEffect(() => {
debouncedSave(data);
}, [data, debouncedSave]);
return { isSaving, lastSaved };
}
// Usage
const { isSaving, lastSaved } = useAutoSave(
canvas,
async (canvas) => {
await supabase.from("canvases").update(canvas).eq("id", canvas.id);
},
1000
);
The Results
After 9 months in production:
- Real-time collaboration for 100+ concurrent users
- Sub-50ms sync latency
- 99.9% uptime
- Zero data loss incidents
- Smooth 60 FPS canvas interactions
Key Takeaways
- Operational Transformation: Essential for real-time collaboration
- Optimistic Updates: Make the UI feel instant
- Conflict Resolution: Last write wins with intelligent merging
- Virtual Scrolling: Critical for large datasets
- Debounced Saves: Balance UX and server load
Building OLAB taught me that real-time collaboration is 80% data synchronization strategy and 20% UI. Get the sync right, and the rest falls into place.
Building collaborative features? I’d love to discuss your architecture challenges.