System Designhard12 min read

Design Google Sheets (Spreadsheet)

A grid that renders a million cells at 60fps: windowing vs canvas, the formula dependency graph and recalculation order, cell editing, and frozen rows.

Published · by Frontend Masters India

"Design Google Sheets" is two hard frontend problems stacked on top of each other. First, render a grid big enough to hold a million cells without melting the browser. Second, when one cell changes, recalculate every formula that depends on it, in the right order, fast enough that typing feels instant. Interviewers like it because you can't bluff either part.

1. Scope it first

Ask before you draw:

  • How big can a sheet get? "A few hundred rows" lets you keep it in the DOM. "Millions of cells" forces windowing or canvas, which changes everything downstream.
  • Do we need formulas now, or just data entry? Formulas mean a dependency graph and recalculation. Plain data entry is mostly a rendering problem.
  • Is it collaborative? Multiple editors add the same concurrency story as a doc. Assume yes but keep it secondary to the grid and formula engine.
  • What's the formula surface? Basic arithmetic and SUM/AVERAGE, or hundreds of functions plus cross-sheet references? Scope the engine to the answer.

Assume a large collaborative sheet with real formulas. Center the answer on rendering and recalculation.

2. The data model

A sparse map, not a 2D array. A million-cell sheet is mostly empty, so don't allocate a million slots. Key cells by coordinate and store only the ones that exist.

type Cell = {
  raw: string;          // what the user typed: "=A1+B1" or "42"
  value: number | string; // computed result
  formula?: AstNode;    // parsed formula, if any
};
type Sheet = Map<string, Cell>;   // key: "A1", "B2"

Keep the raw input and the computed value separate. The raw is what you show when editing; the value is what you show when not. The parsed formula AST lives alongside so you don't re-parse on every recalc.

3. Rendering: you cannot put a million cells in the DOM

A DOM node per cell dies somewhere around tens of thousands of nodes. Two ways out, and you should weigh both.

DOM windowing. Render only the cells in the viewport plus a small buffer, recycle nodes as the user scrolls. This is the same idea as virtualizing a list, in two dimensions. You keep DOM accessibility and easy text selection, but a wide, dense viewport still means thousands of live nodes, and scroll can stutter when you're recycling many cells per frame.

Canvas. Draw the grid yourself onto a <canvas>. One element, no per-cell DOM, and you can repaint the visible region every frame. This is what Google Sheets does. It scales far better and gives you full control of rendering, but you give up native text selection, accessibility, and built-in input, so you reimplement all of it (a hidden input element positioned over the active cell, custom selection rectangles, ARIA work).

The honest answer: windowing for moderate sheets, canvas when you truly need a million cells. Say which you'd pick and why.

// canvas: only draw the visible window
function draw(scrollTop, scrollLeft) {
  const startRow = Math.floor(scrollTop / ROW_H);
  const endRow = startRow + Math.ceil(viewportH / ROW_H);
  for (let r = startRow; r <= endRow; r++) {
    for (let c = startCol; c <= endCol; c++) {
      drawCell(ctx, r, c, getCell(r, c));
    }
  }
}

4. The hard problem: the formula dependency graph

Change A1, and any cell whose formula reads A1 is now stale, and any cell that reads those cells is stale too. You need to recompute the affected cells, in dependency order, and nothing else.

Model dependencies as a directed graph: an edge from A1 to B1 means "B1 depends on A1." When A1 changes, walk forward to find every dependent (the dirty set), then recompute them in topological order so each cell is calculated only after its inputs are fresh.

function recalc(changed) {
  const dirty = collectDependents(changed);     // forward walk
  const order = topoSort(dirty, depGraph);       // inputs before outputs
  for (const cellId of order) {
    sheet.get(cellId).value = evaluate(sheet.get(cellId).formula);
  }
}

Two details that separate a real answer from a hand-wave:

  • Only recompute dirty cells. Recalculating the whole sheet on every keystroke is the naive trap. A changed cell touches maybe a handful of dependents; compute those.
  • Detect cycles. A1 = B1 and B1 = A1 is a circular reference. The topological sort detects this (it can't order a cycle) and you surface a #REF!/circular error instead of looping forever.

For a huge recalc, batch the work and yield to the browser so the main thread stays responsive, or push evaluation into a Web Worker for heavy sheets so typing never blocks on a slow recalculation.

5. Editing a cell

Click or type into a cell and you're in edit mode: show the raw string, not the computed value. On commit (Enter or blur), parse the input. If it starts with =, parse it to an AST, update the dependency graph edges for that cell, then recalc. If it's a literal, just store it and recalc its dependents.

The edit surface itself is a single input element positioned over the active cell, even in the canvas approach. You move and resize one input rather than keeping inputs everywhere. Arrow keys, Tab, and Enter move the active cell; this keyboard model is most of the spreadsheet feel and is easy to get subtly wrong.

6. Frozen rows, columns, and 60fps scroll

Frozen headers (row 1, column A pinned) are separate render layers that don't scroll with the body. In canvas you draw them in fixed regions after the body. In DOM windowing they're sticky-positioned panes outside the scrolling area.

To hold 60fps you have ~16ms per frame. The rules: never touch the DOM or recompute formulas inside the scroll handler; on scroll you only change which window you draw. Throttle paints to requestAnimationFrame so you draw at most once per frame no matter how many scroll events fire.

let pending = false;
scroller.addEventListener("scroll", () => {
  if (pending) return;
  pending = true;
  requestAnimationFrame(() => {
    draw(scroller.scrollTop, scroller.scrollLeft);
    pending = false;
  });
});

7. Edge cases worth naming

  • Wide formulas over big ranges. =SUM(A1:A100000) shouldn't sum the cell one by one through the public API each recalc; keep range evaluation efficient and cache where you can.
  • Paste a 10,000-row block. Batch the model update and run a single recalc at the end, not one per pasted cell.
  • Number formatting and locales. Currency, dates, and decimal separators differ by locale. Store the raw value; format only at draw time.

What the interviewer will push on

  • "DOM or canvas, and when?" Windowing keeps accessibility and selection but caps out on very dense grids; canvas scales to millions of cells at the cost of reimplementing input and a11y. Pick based on the cell count you scoped.
  • "How do you avoid recalculating everything?" Dependency graph plus a dirty set plus topological sort: recompute only what changed and only in valid order.
  • "What about circular references?" The topological sort can't order a cycle, so you detect it there and emit a circular-reference error.
  • "How do you keep scroll smooth?" Sparse model, draw only the visible window, batch paints with requestAnimationFrame, and keep formula work off the scroll path (and off the main thread when it's heavy).
  • "Collaborative edits?" Same conflict-resolution story as a doc, but cell-scoped: a CRDT or OT per cell, with presence showing who's selecting what. Concurrent edits to different cells never conflict, which makes it easier than prose.

The one-paragraph recap

A spreadsheet stores cells in a sparse map keyed by coordinate, keeping raw input separate from computed value, and renders only the visible window, via DOM windowing for moderate sheets or canvas for a true million cells. Formulas form a directed dependency graph: when a cell changes you collect its dependents, topologically sort them, recompute just that dirty set, and detect cycles as circular errors. Editing happens through a single input positioned over the active cell with keyboard navigation between cells, frozen headers render as separate fixed layers, and scroll stays at 60fps by drawing only the window and batching paints with requestAnimationFrame. Lead with the rendering scale problem and the dependency graph and you've shown the two things that actually make this hard.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

More design prompts