Back to blog
Sep 05, 2024
10 min read
Muhammad Waqar Ilyas

Building Real-Time Collaboration: Inside OLAB's Visual Canvas Architecture

Technical deep-dive into building a production-ready collaborative visual canvas with React Flow, real-time syncing, and Notion-style database integration using Next.js and Supabase.

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:

  1. Visual Canvas: Drag-and-drop nodes, connections, real-time updates
  2. Database Views: Table view, kanban, calendar, all synced with canvas
  3. Document Editor: Tiptap-based editor with embedded tables and canvases
  4. Real-time Sync: Changes propagate instantly across all views
  5. 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

  1. Operational Transformation: Essential for real-time collaboration
  2. Optimistic Updates: Make the UI feel instant
  3. Conflict Resolution: Last write wins with intelligent merging
  4. Virtual Scrolling: Critical for large datasets
  5. 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.