"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:
- 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.
- eval().
=A1*2works until someone pastes=alert(document.cookie)โ you've built an XSS machine with a nice grid UI. - 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.
- 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 ASTTrack 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"witharia-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,
requestIdleCallbackfor 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
Mapkeyed 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
- The RADIO Framework
- Design an Autocomplete / Typeahead
- Design a News Feed โ Facebook / Twitter
- Design a Chat Application (Messenger/Slack)
- Design an Image Carousel
- Design a Data Table
- Design Infinite Scroll
- Design a Star Rating Widget
- Design a Kanban Board
- Design Nested Comments
- Design Undo / Redo
- Design a Spreadsheet (this article)