How would you build a collaborative whiteboard in React?

Advanced20 min interview
Skills tested:
Canvas Rendering ArchitectureReal-Time CollaborationCRDT Data ModelingPerformance OptimizationUndo and Redo SystemsPresence and Cursor Synchronization

Advertisement

🧩 Scenario

You're building a Google Jamboard/Miro-style collaborative whiteboard that supports: - Freehand drawing - Shapes (rectangles, arrows, text) - Real-time remote collaboration - Cursor presence for all users - Undo/redo stacks - Zooming and panning - Conflict-free syncing

🧠 Architecture Walkthrough

Why Canvas Rendering Must Stay Imperative

React's declarative model is the right tool for UI that maps cleanly to component trees, but a canvas is an imperative surface. Every call to ctx.beginPath(), ctx.moveTo(), and ctx.stroke() directly mutates a pixel buffer, there is no DOM node per stroke, no reconciliation, and no diffing.

Wrapping canvas operations in React's declarative model by putting every stroke in state and redrawing the entire canvas in a useEffect on every state change is what the demo does, and it is correct for low-frequency updates. The performance ceiling hits when you have thousands of strokes, because each update redraws every prior stroke from scratch.

The production solution is a two-layer canvas: a persistent bottom layer where completed strokes are drawn and never erased, and a temporary top layer that holds only the current in-progress stroke. When mouseup fires, the completed stroke is composited onto the bottom layer and the top layer is cleared. This reduces the per-frame cost from O(all strokes) to O(1) regardless of history depth.

Modelling Drawing as Operations, Not Pixels

The most important architectural decision in a collaborative whiteboard is treating every drawing action as a serializable operation object rather than as a canvas state. In the demo, strokes are stored as { id, points: [{x, y}], color, size } and shapes as { type, x, y, width, height, color, size }.

This means you can replay the entire drawing history from scratch, broadcast individual operations to remote peers, and implement undo by removing the last operation from the list. If you instead saved the canvas as a bitmap and transmitted that, you could never undo individual strokes, you could never merge two users' concurrent drawings, and the network cost would be enormous.

The operation model maps directly to CRDT data structures like Yjs's Y.Array each operation appended to the shared array is automatically merged with operations arriving from other peers, even if they arrive out of order. The canvas then subscribes to the CRDT array and redraws whenever any peer's operations arrive.

Cursor Presence as a Separate Concern from Drawing Data

Cursor positions and drawing strokes have fundamentally different requirements. A stroke is permanent data that must be reliably stored and replayed. A cursor position is ephemeral, it represents where a user is right now, it changes 30–60 times per second, and losing a few updates is completely acceptable.

Mixing them into the same data channel wastes bandwidth and adds latency to stroke delivery. The demo simulates this correctly by keeping users state separate from strokes and shapes, and by updating user cursor positions in handleMouseMove independently of the drawing logic.

In a real implementation, cursor positions would go over a separate ephemeral broadcast channel, WebRTC data channels or a dedicated presence layer in a service like Liveblocks, while strokes go over the CRDT sync channel.

The quadratic curve smoothing in the stroke renderer (ctx.quadraticCurveTo) is also significant: raw {x, y} points produce jagged lines at low pointer sampling rates, and the midpoint quadratic algorithm smooths between consecutive points without requiring a separate smoothing pass.

💡 Key Code Explained

strokes.forEach((stroke) => {
  ctx.strokeStyle = stroke.color;
  ctx.lineWidth = stroke.size;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';

  if (stroke.points.length > 1) {
    ctx.beginPath();
    ctx.moveTo(stroke.points[0].x, stroke.points[0].y);

    for (let i = 1; i < stroke.points.length; i++) {
      const xc = (stroke.points[i].x + stroke.points[i - 1].x) / 2;
      const yc = (stroke.points[i].y + stroke.points[i - 1].y) / 2;
      ctx.quadraticCurveTo(
        stroke.points[i - 1].x,
        stroke.points[i - 1].y,
        xc,
        yc,
      );
    }
    ctx.stroke();
  }
});

The quadratic curve algorithm is the most non-obvious part of stroke rendering. Instead of drawing straight line segments between consecutive mouse positions, it computes the midpoint between each pair of consecutive points and uses the intermediate point as the quadratic control point.

This produces a smooth Bezier-like curve through all the sampled positions. lineCap: 'round' and lineJoin: 'round' ensure that the start, end, and junction of each path segment are rounded rather than flat or sharp, this is what makes hand-drawn strokes look natural.

A junior developer would reach for ctx.lineTo() for each point, which produces angular, jagged strokes at anything less than 120fps pointer sampling. The ctx.beginPath() call before the first moveTo is essential without it, every stroke would connect to wherever the previous stroke ended, creating random lines across the canvas.

const handleMouseMove = useCallback(
  (e) => {
    const pos = getMousePos(e);

    // Simulate user cursor movement
    setUsers((prev) =>
      prev.map((user) => (user.id === 1 ? { ...user, cursor: pos } : user)),
    );

    if (!isDrawing) return;

    if (tool === 'pen' && currentStroke) {
      const updatedStroke = {
        ...currentStroke,
        points: [...currentStroke.points, pos],
      };
      setCurrentStroke(updatedStroke);
      setStrokes((prev) =>
        prev.map((stroke) =>
          stroke.id === currentStroke.id ? updatedStroke : stroke,
        ),
      );
    } else if ((tool === 'rectangle' || tool === 'circle') && startPoint) {
      setCurrentShape({
        type: tool,
        x: Math.min(startPoint.x, pos.x),
        y: Math.min(startPoint.y, pos.y),
        width: Math.abs(pos.x - startPoint.x),
        height: Math.abs(pos.y - startPoint.y),
        color,
        size: brushSize,
      });
    }
  },
  [getMousePos, isDrawing, tool, currentStroke, startPoint, color, brushSize],
);

This handler does two things per mouse move: updates the local user's cursor position in the users array, and appends the new position to the active stroke or updates the shape preview. The stroke update pattern find the stroke by id and replace it in the array means the entire strokes array is replaced on every mouse move event.

This is fine for the demo because the number of strokes is small, but in production this would be a hot path you'd optimize by keeping currentStroke only in a ref during the active drawing phase and only committing it to state on mouseup.

The shape preview logic uses Math.min(startPoint.x, pos.x) for the position and Math.abs(pos.x - startPoint.x) for the width, which correctly handles the case where the user drags in any direction up-left, down-right, or any diagonal without producing negative dimensions.

⚖️ Tradeoffs

ApproachProCon
Operation log in React state (chosen)Simple to implement, enables undo, serializable for persistenceFull redraw on every change; does not scale past a few hundred strokes without optimization
Two-layer canvas (persistent + active)O(1) redraw cost per interaction regardless of history sizeMore complex implementation; requires manual compositing on stroke completion
CRDT (Yjs) with canvas observerConflict-free multi-user merge, scales to many concurrent usersAdds significant bundle size and requires a sync server or WebRTC signaling

🎯 What Interviewers Actually Check

  • Explains the two-layer canvas optimization unprompted rather than just noting that the single-canvas approach is slow
  • Describes storing operations rather than pixels and connects this directly to enabling undo and collaboration
  • Knows that cursor presence and drawing data should use separate transport channels with different reliability guarantees
  • Explains why useCallback on mouse handlers is important — these are attached to a high-frequency event and their deps must be stable
  • Can articulate what CRDTs solve (concurrent conflict-free merging) versus what WebSockets alone solve (real-time delivery)

❓ Follow-Up Questions

  1. Your whiteboard has 10,000 strokes and redraws are taking 200ms. Walk through how you would profile and fix this using the two-layer canvas approach.
  2. Two users delete the same shape simultaneously. With a simple operation log, one delete wins and the other is silently ignored. How does a CRDT model handle this differently?
  3. How would you implement zoom and pan — specifically, how do ctx.scale() and ctx.translate() interact with your mouse position calculations?
  4. A user draws for 10 minutes and then undoes 300 times. How would you make the undo history bounded without surprising the user?
  5. Your team wants to export the whiteboard as a PNG. The canvas makes this trivial with canvas.toDataURL(), but what breaks if the canvas has cross-origin images drawn onto it, and how do you fix it?

🎮 Live Demo

📝 Summary

A collaborative whiteboard's architecture rests on two foundational decisions: treating every drawing action as a serializable operation object rather than a canvas state, and keeping the canvas rendering layer explicitly separate from React's declarative model.

Operations, not pixels are what enable undo, persistence, and conflict-free multi-user merging via CRDTs. The quadratic curve smoothing algorithm and the separation of cursor presence from drawing data are production details that transform a functioning demo into a system that handles high-frequency inputs gracefully.

When the drawing history grows large enough to make full redraws expensive, the two-layer canvas pattern a persistent bottom layer and an ephemeral top layer for the active stroke is the standard optimization, and understanding it is what interviewers look for when they ask this question at the senior level.

Frequently Asked Questions

Should I use Canvas or SVG for drawing?

Canvas is better for freehand drawing and performance; SVG is better for shape editing and DOM-interactive elements.

How do you handle multi-user conflicts?

Use CRDTs or OT to merge operations without overwriting each other.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement