Frontend System Design: Spreadsheet (Excel / Google Sheets)

interviewSeptember 28, 2026ยท 9 min read

"Design a spreadsheet" is where interviewers find out whether you've ever wondered what Excel actually is. The junior answer is a grid of input elements and a formula bar that runs eval(). The senior answer knows a Google Sheet can hold 10 million cells, that changing one number can invalidate a thousand formulas, and that the hard part was never rendering the grid โ€” it's deciding exactly which cells to recompute, in what order, fast enough that the user never sees a dropped frame.

A spreadsheet is three systems stapled together: a rendering problem (a viewport lying about how much exists), a computation problem (a reactive dependency graph disguised as a table), and an editing problem (a keyboard-driven state machine with thirty years of muscle memory fighting you). Let's design all three.

Requirements โ€” the questions that split junior from senior

Before drawing anything, ask:

  • How big? A wedding-planner table is 50 rows. A financial model is 100k rows. Excel offers 1,048,576 rows by 16,384 columns. The rendering architecture that's fine at 50 rows is fatal at a million, so nail the ceiling first.
  • Formulas? Just literals and simple sums, or full functions (VLOOKUP, SUMIF, cross-sheet refs, array formulas)? The moment formulas exist, you own a dependency graph whether you planned to or not.
  • Real-time collaboration? Two people in one sheet means per-cell conflict rules and reordering operations โ€” a very different beast from single-user.
  • Offline? Editing on a flaky train means an operation log in IndexedDB, not just autosave-to-server.
  • Paste fidelity? Users paste from Excel constantly. Do you accept HTML, TSV, or both?

The answers pick your scope. I'll assume the full production version โ€” a million cells, formulas, collaboration, offline โ€” because that's where the interview points end up anyway.

The naive answer โ€” and exactly where it dies

// The answer everyone gives first: render the grid, eval the formulas
function Sheet({ rows, cols }) {
  return (
    <table>
      {rows.map((r) => (
        <tr key={r}>
          {cols.map((c) => (
            <td key={c}>
              <input
                value={evalFormula(cells[`${c}${r}`])}
                onChange={(e) => setCell(`${c}${r}`, e.target.value)}
              />
            </td>
          ))}
        </tr>
      ))}
    </table>
  );
}

This passes a phone screen and dies on contact with reality:

  1. DOM weight. 1,000 rows by 26 columns is 26,000 inputs. Each input costs the browser real memory; scrolling allocates nothing new but repaints a mountain. At 50k rows the tab is gone.
  2. eval(). =A1*2 works until someone pastes =alert(document.cookie) โ€” you've built an XSS machine with a nice grid UI.
  3. Full recalc. Change A1 and every formula re-runs, including the 999 that don't depend on A1. On a financial model that's a two-second freeze per keystroke.
  4. Re-render storms. Every keystroke re-renders the entire table because state lives in one giant object.

Each failure has a nameable fix. That's the rest of this design.

Rendering โ€” lie about how much exists

Never render the sheet. Render the window of the sheet that's visible, plus a small overscan margin, and keep the scrollbar honest with a spacer.

const OVERSCAN = 5, ROW_H = 24;
const firstRow = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN);
const rowCount = Math.ceil(viewportHeight / ROW_H) + OVERSCAN * 2;
// 800px viewport โ†’ ~44 rows in the DOM, whether the sheet has 10 or 1,000,000

// Scrollbar honesty: one spacer element with height = totalRows * ROW_H
// gives the scroll container its full virtual extent; the ~44 real rows
// absolutely position at top = firstRow * ROW_H.

Column windows work identically for wide sheets โ€” virtualize both axes past ~100 columns.

  • Uniform rows mean O(1) index math. Variable heights (wrapped text) mean a prefix-sum array plus binary search to find which row a scrollTop lands on. Say this out loud; it's the classic follow-up.
  • Sticky headers (row numbers, the A/B/C letters) are separate, non-virtualized strips that translate with scroll โ€” they're tiny and always mounted.
  • Selection overlays (the blue range rectangle, the drag handle) are absolutely-positioned divs above the grid, never per-cell classNames โ€” repainting a className across 26k cells on every selection change is self-inflicted jank.

The grid itself can be CSS grid, divs, or canvas โ€” implementation detail. The invariant that matters: DOM node count is a function of viewport size, never of sheet size.

Data model โ€” sparse, keyed, normalized

Users leave gaps; a sheet with data in A1 and Z999 is 99.99% empty. So don't store a matrix โ€” store a Map keyed by A1-style address:

// Sparse cell store โ€” memory proportional to *used* cells, not sheet bounds
const cells = new Map(); // "B7" โ†’ cell record

function getCell(ref) {
  return cells.get(ref) ?? { raw: "", value: null }; // virtual empty cell
}

// One record per used cell:
// {
//   raw: "=A1*2",           // what the user typed
//   ast: { op: "mul", ... },// parsed formula (null for literals)
//   value: 42,              // computed value
//   deps: ["A1"],           // addresses this cell reads
//   fmt: "0.00"             // display format
// }

The store is the single source of truth; the virtualized grid is just a projection of it. Undo/redo rides on top as commands ("set B7 raw from X to Y") or structural-sharing snapshots โ€” see the undo/redo design for that half.

The formula engine โ€” a reactive graph wearing a grid costume

This is the heart of the interview. Changing A1 must recompute =A1*2 and everything downstream of that โ€” but nothing else.

Parse once. Tokenize and parse raw into an AST at edit time (never per keystroke, never eval):

// "=SUM(B1:B3)*A1" โ†’
// Mul( Call("SUM", [Range("B1","B3")]), Ref("A1") )
const value = evaluate(ast, getCellValue); // evaluator walks the AST

Track dependencies. While evaluating, record every Ref and Range the cell touches, and build two maps: depsOf[cell] (what I read) and dependentsOf[cell] (who reads me). That's your dependency graph โ€” directed, and if all is well, acyclic.

Recalculate incrementally, in order. When A1 changes, walk dependentsOf transitively to collect the dirty set, then re-evaluate in topological order (Kahn's algorithm โ€” evaluate cells whose dependencies are all clean, wave by wave):

function recalc(changedRef) {
  // 1. Dirty set: transitive closure of dependents
  const dirty = new Set();
  const queue = [changedRef];
  while (queue.length) {
    const ref = queue.pop();
    for (const dep of dependentsOf.get(ref) ?? []) {
      if (!dirty.has(dep)) { dirty.add(dep); queue.push(dep); }
    }
  }
  // 2. Evaluate in waves: only cells whose deps are all clean
  while (dirty.size) {
    const ready = [...dirty].filter((ref) =>
      (depsOf.get(ref) ?? []).every((d) => !dirty.has(d)));
    if (!ready.length) throw new Error("CIRCULAR"); // covered below
    for (const ref of ready) {
      cells.get(ref).value = evaluate(cells.get(ref).ast, getCellValue);
      dirty.delete(ref);
    }
  }
}

Change one cell in a 100k-formula model and this touches its dozen true dependents instead of all 100k. That's the difference between Excel and a spreadsheet that freezes.

Off the main thread. Even incremental recalc can blow the 16ms frame budget on a huge paste โ€” pasting 50k cells creates 50k formulas at once. Move the engine (parse, evaluate, graph) into a Web Worker; the UI thread only ever renders committed values. Ship edit batches in, get value maps back โ€” transferable ArrayBuffer payloads keep the copies cheap (see transferable objects).

Circular references โ€” the trap that ends interviews

Put =A1 in B1 and =B1 in A1. The topo sort's ready list comes up empty while dirty still has members โ€” that's cycle detection, for free. What you do next is the answer:

  • Fail closed per-cell, not per-sheet. Google Sheets puts #REF! with "circular dependency detected" on the cycle members; Excel shows a circular-reference warning and stops calculating those cells. Cells downstream of a cycle inherit the error โ€” the error is a value, and it flows like one. The rest of the sheet keeps working.
  • Guard incremental edits. When the user types a formula, evaluate it tentatively against the graph; if it would create a cycle, reject or annotate rather than corrupting sheet state.
  • Know the exception. Iterative calculation (Excel's "Enable iterative calculation", Goal Seek) wants a fixed point: run the cycle up to N iterations and check for convergence. Mentioning this unprompted is senior signal โ€” it shows you know cycles are sometimes a feature.

Editing โ€” a keyboard-driven state machine

The grid is not 26,000 inputs. It's one editor overlay:

  • Navigate mode: arrows, Enter, and Tab move the active cell; the selection rectangle is a div, not per-cell state.
  • Edit mode: typing or F2 mounts a single absolutely-positioned input over the active cell; the formula bar mirrors it. Escape reverts, Enter commits and moves down โ€” thirty years of muscle memory, all of it non-negotiable.
  • While editing a formula, highlight referenced ranges on the grid (the colored borders in Excel and Sheets) โ€” read straight off the parsed AST.
  • Accessibility: role="grid" with aria-selected, focus management on the active cell, live-region announcements for evaluated results. Screen-reader users navigate spreadsheets by cell address; the model, not the DOM count, is the truth.

Clipboard โ€” the unglamorous 20% of the work

Nobody asks about paste until they've shipped. Real spreadsheets put both TSV and an HTML table on the clipboard; Excel reads the HTML, everything else reads the TSV. Your paste handler:

async function onPaste(e) {
  const html = e.clipboardData.getData("text/html");
  const table = html
    ? parseHtmlTable(html)                  // cells with types + formats
    : parseTSV(e.clipboardData.getData("text/plain"));
  applyPaste(activeCell, table);            // one op, one undo entry
}

A multi-cell paste is a single undoable operation covering the whole range โ€” one op per cell is the "typing hello costs five undos" bug from the undo/redo design, wearing a grid costume. Bulk paste also means bulk graph updates: rebase relative refs (A1+1 pasted one row down becomes A2+1) โ€” a transform over the AST, not string surgery on the raw formula.

Persistence and collaboration

  • Single-user: debounced (500msโ€“2s) snapshots or an operation log to the server, plus IndexedDB for crash recovery. Op logs beat snapshots once sheets get big โ€” and you're already generating the ops.
  • Multi-user: per-cell OT or CRDT. Concurrent edits to different cells commute trivially; same-cell conflicts are last-write-wins or character-level merges. Formula edits rebased on remote changes must re-resolve their refs โ€” the same machinery as paste rebasing.
  • Offline: queue ops in IndexedDB with client timestamps, replay on reconnect, and version-stamp everything โ€” an in-flight save and a user edit are two writers to the same document, the same race the undo/redo design solves.

Interview gotchas โ€” quick-fire

  • Answering "1M rows" with a real table โ†’ DOM count must be viewport-bound; say "virtual scrolling, both axes" in the first five minutes.
  • Paste triggering 50,000 re-renders โ†’ batch the paste into one state commit; the grid never sees intermediate states.
  • Typing in one cell lagging โ†’ the editor overlay writes local state only; commit is decoupled from recalc.
  • eval() on formulas โ†’ XSS; parse to an AST with your own tokenizer and a sandboxed function whitelist.
  • Everything freezing on recalc โ†’ incremental dirty-set recalculation, worker thread, requestIdleCallback for off-screen cells.
  • Circular ref crashing the app instead of annotating a cell โ†’ errors are values.
  • Scrollbar lying about content size โ†’ spacer div sized to the full virtual extent.
  • Losing unsaved edits on refresh โ†’ op log in IndexedDB, replay on load.

Putting it together

A production spreadsheet design:

  • Rendering: a two-axis virtualized grid โ€” DOM count is a function of the viewport, never of the data; sticky headers and selection live as overlay layers
  • Data: sparse Map keyed by A1 address as the single source of truth; the grid is a projection
  • Computation: parse-once ASTs, dependency and dependents maps, dirty-set collection, topological recalculation โ€” recompute exactly what a change invalidates, in order, off the main thread
  • Cycles: detected for free by the topo sort; per-cell error values; iterative calculation as the known exception
  • Editing: one mounted editor overlay, a keyboard state machine, formula-bar mirror, ref highlighting from the AST
  • Clipboard: TSV + HTML dual format, whole-paste-as-one-op with AST-level ref rebasing
  • Persistence: debounced op log, IndexedDB offline queue, per-cell collaboration with rebase-on-remote

The grid is the easy 10%. The dependency graph is the product โ€” and the answer.

The Series: Frontend System Design Interview Questions

Donate
ยฉ 2024, Built with Gatsby