cf.completefrontend
My progress
SYSTEM DESIGN · #99

Design Google Sheets

A collaborative spreadsheet in the browser: a grid that scrolls smoothly over millions of cells, a selection and editing model that feels like a desktop app, a formula engine that recalculates only what changed, and many people editing the same sheet at once.

The question

Design the frontend of Google Sheets. A user opens a spreadsheet with several sheets, some with 100,000 rows and 50 columns. They scroll, select ranges, type values and formulas like =SUM(B2:B9000), copy and paste between sheets and other apps, and undo their mistakes.

Several colleagues edit the same file at the same time and see each other's selections. Walk me through how you render the grid, how selection and editing work, how formulas are calculated and kept up to date, how the data is stored on the client, and how edits from different people are merged.

Real-life examples: Google Sheets, Microsoft Excel for the web, Airtable's grid view, Apple Numbers on iCloud, Zoho Sheet

Requirements: what to ask before drawing anything

A spreadsheet is a grid of cells. Each cell holds a value (a number, some text, a date) or a formula: a small program such as =A1*2 whose result depends on other cells. The grid looks simple, but a spreadsheet is really three products in one: a rendering engine that draws millions of cells, a small programming language with its own runtime, and a shared document that many people change at once.

The interviewer gave you a big name and little detail. Your first job is to shrink the problem to something you can design in an hour. Spend the first five minutes on questions. Each question below is worth asking because its answer changes a real decision later.

QuestionAnswer we assumeWhat it changes
How big can one sheet get?Up to 20 million cells per file, the Google Sheets limit since September 2026 (it was 10 million before); a typical heavy sheet is 100,000 rows by 50 columnsWe can't create a DOM node per cell, and we can't load the whole file before showing anything
Do we need formulas, or just values?Formulas, with the common functions (SUM, IF, VLOOKUP, NOW) and cross-sheet referencesWe need a parser, a dependency graph, and a recalculation engine that must not freeze the page
Is it collaborative?Yes. Up to 100 people can have the file open; a handful type at onceWe need a sync protocol, conflict rules for cells and for structure changes, and presence
Which devices?Desktop browsers first; mobile can view and do light editsKeyboard and mouse matter most; touch scrolling must still be smooth
Offline?Nice to have; keep working through short network dropsWe need an outbox of unsent edits and a way to replay them
Charts, pivot tables, scripts?Out of scope for this hourKeeps the design focused on grid, formulas, and sync

Functional requirements

  • Open a file with several sheets (tabs); switch sheets.
  • Scroll a large grid smoothly in both directions, with frozen header rows and columns.
  • Select a cell, a range, whole rows or columns, and several ranges at once; move with the keyboard like a desktop spreadsheet.
  • Type a value or a formula into a cell, or into the formula bar; see results update right away.
  • Resize rows and columns; insert and delete rows and columns.
  • Format numbers, dates, and currencies; text style such as bold and background color.
  • Copy and paste inside the sheet and to and from other apps such as Excel or a web page.
  • Undo and redo your own changes.
  • See other editors' selections and their edits within a second.

Non-functional requirements, with numbers

  • Scrolling at 60 frames per second: each frame has about 16 ms, and the browser needs some of it, so our drawing work per frame should stay under about 8 ms.
  • Typing feels instant: a key press shows in the cell within one frame. Input delay is what users feel most; Interaction to Next Paint (INP) should stay under 200 ms even for heavy edits.
  • Recalculation: a change that touches 10,000 dependent formulas should show new results within about 100 to 300 ms, and never block typing or scrolling while it runs.
  • First view: the first screen of cells is visible within about 2 seconds on a laptop with a normal connection, even for a 50 MB file.
  • Memory: a 20-million-cell file must fit in the tab. At 100 bytes per cell that is already about 2 GB, so we will need compact storage and lazy loading.
  • Collaboration: other people's edits appear within about 1 second; nobody's edit is silently lost.
  • Accessible: usable with the keyboard alone and with a screen reader.

Out of scope, and why

Charts, pivot tables, filter views, scripting (like Apps Script), and import of every Excel feature are each a full design question of their own. Say so out loud, and mention where they would plug in later: charts read ranges from the same cell store, and scripts call the same edit API that the UI uses. The backend storage design is also out of scope: we treat the server as a service that orders edits, stores them, and sends them back out.

Wireframe of the spreadsheet screen. At the top, a toolbar, then a formula bar. Below, column headers A, B, C and row numbers. Row 1 is frozen and holds Month, Revenue, Cost. Row 2 has Jan, 1,200, 800, with B2 as the active cell. Row 3 has Feb, 1,350, 910, where cell C3 shows a collaborator's selection. Row 4 has Mar and 1,410, and the editor overlay sits over C4 with the text =B4*0.7. Sheet tabs Data and Summary are at the bottom.FY26 budgetundo, bold, 0.00, %, fillC4 | =B4*0.7ABC1MonthRevenueCost2Jan1,2008003Feb1,3509104Mar1,410=B4*0.7DataSummarytoolbarformula barcolumn headersfrozen rowactive cell B2Ben selects C3editor over C4sheet tabs
Everything inside the grid area is painted on canvas except the editor over C4, which is a real text field. The frozen row stays put while the rows below it scroll.

The wireframe names the parts we will design: the toolbar and formula bar at the top, the column and row headers, the frozen panes, the main scrolling grid, the editor that appears over a cell, the colored boxes of other editors, and the sheet tabs at the bottom.

Check your understanding · Exercise 1

Which answers from the interviewer would change your design the most? Select all that apply.

What makes a spreadsheet hard

Before picking any technology, name the forces that push on every later decision. A spreadsheet has five of them, and each one rules out the easy answer.

  1. It is huge in two directions. A news feed is long but one item wide. A sheet with 100,000 rows and 50 columns has 5 million cells, and the user can jump to any of them with Ctrl+End or a scrollbar drag. We have to virtualize rows and columns at the same time.
  2. Every cell can depend on every other cell. =SUM(B2:B9000) reads 8,999 cells. Change B5 and that sum must update, and so must every formula that reads the sum, and so on. A spreadsheet is a live program, and the user writes it one cell at a time.
  3. The user expects desktop speed. People have used Excel for decades. They hold the arrow key down and expect the selection to glide; they paste 20,000 rows and expect it to work. Any pause over about 100 ms feels broken.
  4. It is shared. Five people edit the same sheet. One inserts a row above row 10 while another types into A12. Both edits must land in the right place on every screen, and the formulas that point at moved cells must follow them.
  5. The data is personal and precise. A wrong number in a budget is worse than a slow page. Recalculation must be correct, undo must undo exactly what you did, and a dropped connection must never lose an edit.

These forces map to the chapters that follow. Size drives rendering and loading; dependencies drive the formula engine and where it runs. Desktop speed drives the selection model, the editor, and the performance budget. Sharing drives the sync design and the data model; precision drives undo, clipboard handling, and number formats.

A quick estimate

Numbers make the problem concrete. Assume a laptop screen shows about 40 rows and 15 columns at once: 600 visible cells. The heavy sheet has 5 million cells, so we show about 0.01 percent of it at any moment.

If one cell costs about 1 KB of DOM and style data, rendering all of them would need about 5 GB: impossible. Rendering only the visible 600 plus a margin needs about 1 MB: easy. That ratio is the whole argument for virtualization, and we will build on it in the next chapter.

For formulas, assume 20 percent of the cells hold formulas: 1 million of them. A full recalculation that spends just 1 microsecond per formula takes 1 second, which is far too slow to run on every key press. So we must recalculate only the formulas that depend on the changed cell, and we must do it away from the main thread.

Think first

A data table with 100,000 rows and a spreadsheet with 100,000 rows look alike. Name two things the spreadsheet must do that the data table usually doesn't.

Rendering a huge grid: DOM or canvas

Start with the naive approach, because the interviewer wants to see you know why it fails. The naive grid is an HTML table with one td per cell. For a 20 by 20 sheet it is perfect: text selection, accessibility, and styling all work by default.

Now load the heavy sheet: 100,000 rows by 50 columns. That is 5 million td elements. At roughly 1 KB of memory per element with its styles, that is gigabytes, and every scroll or style change would make the browser recompute style and layout for a tree that size.

Long before that, at a few hundred thousand cells, building the table takes seconds and the tab stops responding. The naive grid dies long before we reach real sizes.

The fix is virtualization: draw only the cells inside the viewport plus a small margin, and fake the rest of the scroll height and width with an empty spacer element. The lesson on virtualization covers the one-direction case for lists. A spreadsheet needs it in two directions at once: a window of rows and a window of columns, and we draw their crossing. The real question is how we draw the cells inside that window.

Decision How should we draw the visible cells?

DOM, virtualized in two directions

A scroll container holds a spacer as big as the whole sheet.

On scroll, compute the visible rows and columns and render one absolutely positioned div per visible cell, reusing nodes as they leave the window.

Pros

  • Text, fonts, borders, and links render with normal CSS; browser find and text selection partly work.
  • Easier to make accessible: cells can carry role="gridcell" directly.
  • The approach most DOM grid libraries (like AG Grid) take; well understood.

Cons

  • A full laptop screen is 600 to 1,500 cells, each with styles. Fast scrolling must create or update hundreds of nodes per frame, which costs style and layout time.
  • Native scrolling runs on the compositor thread, ahead of JavaScript, so fast flings show blank gaps before new cells arrive.
  • Rich formatting per cell (borders shared between neighbors, merged cells, overflow text into empty neighbors) is awkward in CSS.

Canvas for the cells

One canvas element the size of the viewport stays in place while a spacer behind it provides native scrollbars.

On each scroll or change, a draw function paints the visible cells: fills, grid lines, and text with fillText, in one pass.

Pros

  • Cost depends on visible cells only, with no DOM nodes, no style recalculation, and no layout per cell.
  • Full control: shared borders, text overflowing into empty neighbors, merged cells, and other users' selections are just more drawing.
  • Large products use it: inspect Google Sheets and you find canvas elements, not a node per cell; Google Docs announced its move to canvas rendering in 2021; and the open source Glide Data Grid is canvas based.

Cons

  • Nothing on the canvas is accessible; screen readers see one blank image. We must build a separate accessible layer.
  • No native text selection, browser find, or spell check inside cells; we build our own find and overlay a real input for editing.
  • We own the details: sharp text on high-density screens, text measurement, clipping, and hit testing clicks to cells.

Hybrid: canvas grid plus DOM overlays

Canvas paints the cell grid, selections, and remote cursors.

Real DOM elements sit on top only where interaction needs them: the cell editor, the fill handle, menus, comment popups, and a hidden accessible grid for screen readers.

Pros

  • Canvas speed for the thousands of cells, DOM correctness for the few interactive pieces.
  • Editing uses a real text field, so IME, spell check, autocorrect, and paste all work.

Cons

  • Two worlds to keep aligned: the overlay must move exactly with the canvas when scrolling and zooming.
  • More code than either pure approach.

What we pick: Pick the hybrid. At spreadsheet scale, the per-cell DOM cost is the bottleneck, and canvas removes it: drawing 1,000 cells with fillRect and fillText typically takes a few milliseconds. Keep the DOM for what canvas can't do well: text input, focusable controls, and the accessible layer. If the interviewer pushes on a smaller product (say, 5,000 rows of plain values), a DOM grid with two-direction virtualization is a fine and simpler answer, and saying so shows judgment.

Two ways to draw the visible cells, side by side. On the left, a DOM grid: twelve boxes each labelled div stand for about 1,200 nodes, with notes that style and layout run on scroll and fast flings show blank gaps. On the right, one canvas with the cells drawn into it and a single textarea overlay on top, with notes that there is no DOM node per cell, draw cost follows the visible cells, and accessibility needs a DOM mirror.DOM griddivdivdivdivdivdivdivdivdivdivdivdivabout 1,200 nodes to keepstyle and layout on scrollblank gaps on fast flingscanvas plus overlaysone canvas: cells drawntextareano DOM node per cellcost: visible cells onlya11y needs a DOM mirror
A full laptop screen holds 600 to 1,500 cells. On the left each one is a styled element the browser must lay out; on the right they are pixels from one draw pass, and only the textarea editor is real DOM.

How the canvas draw loop works

The canvas never moves. A scroll container with a spacer of the full sheet size gives us real scrollbars, trackpad momentum, and keyboard scrolling with no extra code. The canvas sits in that container with position: sticky at the top left.

When the container fires a scroll event, we don't draw right away: we mark the frame dirty and schedule one draw with requestAnimationFrame. Several scroll events in the same frame then cost one draw, not several.

ts
function draw(ctx: CanvasRenderingContext2D, view: Viewport) {
  const rows = visibleRange(rowCache, view.scrollTop, view.height, 3);
  const cols = visibleRange(colCache, view.scrollLeft, view.width, 2);
  ctx.clearRect(0, 0, view.width, view.height);
  for (let r = rows.start; r <= rows.end; r++) {
    const y = rowCache.offsetOf(r) - view.scrollTop;
    for (let c = cols.start; c <= cols.end; c++) {
      const x = colCache.offsetOf(c) - view.scrollLeft;
      const cell = store.get(view.sheetId, r, c); // sparse: often undefined
      if (cell) paintCell(ctx, cell, x, y, colCache.sizeOf(c), rowCache.sizeOf(r));
    }
  }
  paintGridLines(ctx, rows, cols, view);
  paintSelections(ctx, selection, remoteSelections, view);
}
One frame: find the visible window, then paint it. Sizes come from position caches (next chapter).

Four details separate a sharp, fast canvas grid from a blurry, slow one:

  • High-density screens. Set the canvas's pixel size to its CSS size times window.devicePixelRatio, then call ctx.scale(dpr, dpr). Without this, text looks blurry on a 2x laptop screen. Listen for ratio changes (the user zooms the page or drags the window to another monitor) and resize.
  • Text measurement is the slow part. measureText and font switches cost more than filling rectangles. Cache measured widths per font and string, set the font once per run of cells with the same style, and only measure when text might overflow its cell.
  • Clip each cell. Long text must stop at the cell edge unless the next cell is empty, in which case spreadsheets let it spill over. Decide overflow first, then clip with a rectangle path; drawing outside a cell and painting over it later wastes time.
  • Layers. Use two stacked canvases: one for cells and one for selection boxes and remote cursors. Moving the selection with an arrow key then repaints only the light top layer, not 1,000 cells.

The window is a little bigger than the viewport: we draw a few extra rows and columns, called overscan, so a small scroll doesn't reveal a blank edge before the next frame. Try it: scroll the lab below and watch how many cells are drawn compared to the sheet size.

A 100,000 by 50 sheet with a viewport of 6 rows by 4 columns starting at A1. With overscan 1, rows 1 to 7 and columns A to E are drawn: 35 cells out of 5,000,000.whole sheet100,000 x 50ABCDE1234567drawn: 35 of 5,000,000 cellsrows 1 to 7columns A to E
Move the sliders or press a key button. Hatched cells are overscan: drawn, but just outside the viewport.

Check your understanding · Exercise 2

You chose a canvas grid with DOM overlays. Which statements are true? Select all that apply.

Sizes, frozen panes, and smooth scrolling

The draw loop asked two questions every frame: "which row is at pixel 84,000?" and "where does row 4,000 start?". If every row had the same height, both answers would be one multiplication.

Real sheets are messier: the user drags row 3 to 60 px, wraps text in row 900, hides rows 10 to 50 with a filter, and widens column B. We need a structure that answers both questions fast for any mix of sizes. Call it a position cache, one per axis (rows and columns).

The naive answer adds up heights from row 0 every time we need an offset. Row 90,000 then costs 90,000 additions, and we ask that question for every visible row in every frame. At 60 frames per second it burns the whole frame budget on arithmetic.

Decision How do we map between row index and pixel offset?

Sum on demand

Keep an array of heights; add them up from 0 each time an offset is needed.

Pros

  • No extra memory; resizing a row is a single write.

Cons

  • Each query is O(n). Far too slow for 100,000 rows at 60 frames per second.

Prefix sum array

Keep offsets[i] = the sum of heights of rows before i; find the row at a pixel with binary search.

Pros

  • Offset lookups are O(1); pixel-to-row is O(log n).

Cons

  • Resizing row 5 shifts every offset after it: O(n) per resize. Dragging a row border fires dozens of resizes per second.

Default size plus a tree of differences

Most rows have the default height (say 21 px). Store only the difference for rows that don't, in a Fenwick tree (a binary indexed tree), which keeps running sums in O(log n).

offsetOf(i) = i * defaultSize + the sum of differences for rows before i.

Pros

  • Both lookup and resize are O(log n), about 17 steps for 100,000 rows.
  • Hidden rows are just a size of 0, so filters and grouping need no special case.

Cons

  • Inserting or deleting rows shifts indices, so the tree is rebuilt: O(n), but that happens once per structure change, not per frame.

What we pick: Use the default size plus a tree of differences. It keeps frame-time queries and resize drags both cheap, and it is about 30 lines of code. For small sheets a prefix sum array is fine; mention the trade-off and move on.

A worked example shows why storing only the differences is compact. The default row height is 21 px; row 3 was resized to 60 px (a difference of +39) and row 7 is hidden (a difference of -21). So row 10 starts at 10 * 21 + 39 - 21 = 228 px. Two numbers stored, any offset answered.

ts
class AxisCache {
  private tree: Float64Array; // Fenwick tree of (size - defaultSize)
  private sizes = new Map<number, number>(); // only non-default sizes
  constructor(private count: number, private def: number) {
    this.tree = new Float64Array(count + 1);
  }
  sizeOf(i: number) { return this.sizes.get(i) ?? this.def; }
  setSize(i: number, size: number) {
    const delta = size - this.sizeOf(i);
    this.sizes.set(i, size);
    for (let k = i + 1; k <= this.count; k += k & -k) this.tree[k] += delta;
  }
  offsetOf(i: number) { // start of item i, in px
    let sum = i * this.def;
    for (let k = i; k > 0; k -= k & -k) sum += this.tree[k];
    return sum;
  }
  indexAt(px: number) { // last item whose start is at or before px
    let lo = 0, hi = this.count - 1;
    while (lo < hi) {
      const mid = (lo + hi + 1) >> 1;
      if (this.offsetOf(mid) <= px) lo = mid; else hi = mid - 1;
    }
    return lo;
  }
}
A position cache for one axis. indexAt finds the row or column under a pixel with binary search.
A position cache for rows. The default row height is 21 px. Rows 0 to 2 start at 0, 21, and 42. Row 3 was resized to 60 px and starts at 63. Rows 4 to 6 start at 123, 144, and 165. Row 7 is hidden with a height of 0, so rows 7 and 8 both start at 186, and row 9 at 207. Row 10 starts at 228. Only two differences are stored: row 3 plus 39 and row 7 minus 21, so offsetOf(10) is 10 times 21 plus 39 minus 21, which is 228.row 00row 121row 242row 363row 4123row 5144row 6165row 8186 (row 7: 0 px)row 9207row 10 starts at 228 pxdefault size: 21 pxrow 3: +39 (60 px)row 7: -21 (hidden)offsetOf(10)= 10 x 21 + 39 - 21= 228two stored numbers
The cache stores the default size and only the rows that differ. offsetOf(10) is one multiplication plus the stored differences before row 10, kept in a Fenwick tree so both lookups and resizes take O(log n).

Frozen rows and columns

Users freeze the header row and the first column so labels stay visible while they scroll. With frozen panes, the viewport is really four regions that scroll differently, and each one is a separate clip rectangle in the same draw pass:

  • Corner: frozen rows times frozen columns. It never scrolls.
  • Top band: frozen rows. It scrolls left and right with the body, never up and down.
  • Left band: frozen columns. It scrolls up and down with the body, never left and right.
  • Body: everything else. It scrolls both ways, and its visible window starts after the frozen rows and columns.

The row and column headers (the A, B, C strip and the 1, 2, 3 strip) are just two more bands drawn by the same rules. Hit testing a click follows the same split: first decide which region the point is in, then use that region's scroll offset to turn the pixel into a row and column. Selections that cross a frozen boundary are drawn in each region separately and clipped, so the blue box looks continuous.

Frozen panes split the viewport into four regions. Top left, the corner, holds frozen rows and frozen columns and never scrolls. Top right, the top band, holds frozen rows and scrolls only left and right. Bottom left, the left band, holds frozen columns and scrolls only up and down. Bottom right, the body, scrolls both ways.cornernever scrollstop band: frozen rowsscrolls left and rightleft bandup and downbodyscrolls both waysfour clip rectangles, one draw pass
Each region is a clip rectangle drawn in the same pass with its own scroll offset. Hit testing a click first asks which region the point is in, then which row and column.

Smooth scrolling, and the giant-sheet scrollbar problem

We keep native scrolling because it gives momentum on trackpads, scrollbar dragging, and accessibility with no extra code. Two problems remain.

First, browsers cap how tall an element can be. The exact limit differs by browser, but it is in the range of tens of millions of pixels. A sheet with 2 million rows at 21 px is 42 million pixels, which can be past the cap, so the spacer silently stops growing and the last rows become unreachable.

The fix is a scaled scrollbar: when the true size is over a safe limit (say 10 million px), the spacer is that limit, and we map the scroll position to the sheet with a ratio: sheetY = scrollTop * (totalHeight - viewport) / (spacerHeight - viewport). Small wheel movements are then handled in sheet pixels, so one notch still moves about three rows, not thousands.

Second, many spreadsheets snap vertical scrolling to whole rows, so the top row is never cut in half. It also makes the scaled math easier: map the scroll position to a row index first, then draw from that row. Horizontal scrolling usually stays pixel-smooth because columns are wide.

Check your understanding · Exercise 3

Default row height is 20 px. Row 2 is 50 px tall and row 4 is hidden. Which statements are true? Select all that apply.

The selection model and keyboard navigation

Selection looks like a highlight, but it is the main input device of a spreadsheet. Almost every command (bold, delete, copy, sum, insert row) acts on "the selection". If the model is too simple, half the keyboard shortcuts become impossible to build.

The naive model is one rectangle: { top, left, bottom, right }. It breaks on the first real interaction.

Click B4, press Shift+Down twice, then Shift+Up once. Is the range now B4:B5 or B5:B6? It depends on which corner stayed fixed, and a plain rectangle doesn't remember. It also can't express Ctrl+click adding a second range, and it doesn't know which cell inside the range receives typing.

What a selection really holds

ts
type CellRef = { row: number; col: number };
type Range = { top: number; left: number; bottom: number; right: number };

type Selection = {
  sheetId: string;
  ranges: Range[];       // one or more; Ctrl+click adds another
  activeRange: number;   // which range keyboard extension changes
  anchor: CellRef;       // the fixed corner while extending
  active: CellRef;       // the cell that receives typing; inside ranges[activeRange]
};
The selection is local UI state. The document never stores it; collaborators see it as presence.

Three ideas live in that type. The active cell is the one with the thick border: typing goes there and the formula bar shows its content. The anchor is the corner that stays put while you extend with Shift.

The far corner is the one that moves; it is not the active cell, which surprises many people: in both Excel and Google Sheets, Shift+Down grows the range while the active cell stays where it was. And several ranges let a user pick A1:A10 and C1:C10 together to chart or format them.

A selection on a grid of columns B to F and rows 2 to 6. The first range is C3 to D5. C3 is both the anchor and the active cell, drawn with a thick border. D5 is the far corner, which moves when the user presses Shift and an arrow. A second range, F3 to F4, was added with Ctrl and click.BCDEF23C345D56C3: anchor and activeD5: far cornerF3:F4: Ctrl+click rangeShift+Down moves D5, not C3Enter walks C3, C4, C5, D3, ...
Shift plus an arrow moves the far corner while the active cell stays put; Enter walks the active cell through the range. Ctrl and click pushes a second range onto the list.

The keyboard map

The keyboard navigation lesson covers roving focus and the ARIA grid pattern in general. A spreadsheet adds a long list of shortcuts that users have memorized. Each one is a small pure function from the old selection to the new one, which makes them easy to unit test.

KeysWhat happensModel change
ArrowMove one cellCollapse to one range; active and anchor move together
Shift + ArrowGrow or shrink the rangeMove the far corner; active stays
Ctrl + Arrow (Cmd on Mac)Jump to the edge of the dataMove active to the data edge (rule below)
Ctrl + Shift + ArrowExtend to the edge of the dataMove the far corner to the data edge
Enter / TabMove down / right, inside the range if one is selectedMove active within the range, wrapping at its edges
Ctrl + ASelect all (Excel first selects the current data region, then the sheet)Replace ranges with one range
Shift + Space / Ctrl + SpaceSelect whole row / whole columnRange spans all columns or all rows
Ctrl + clickAdd another rangePush a new range; it becomes the active range

The data edge rule for Ctrl+Down is worth stating precisely, because it is a classic follow-up. If the current cell and the one below are both filled, move to the last filled cell before a gap. Otherwise (the current cell or the one below is empty), jump to the next filled cell further down. If there is none, go to the last row of the sheet.

This needs a fast "next filled cell in this column after row r" query, so the cell store keeps each column's filled rows in sorted order (more in the data model chapter). Scanning 100,000 empty rows one by one on each key press would be too slow.

Mouse dragging and the fill handle

A drag selection starts on pointerdown, which sets the anchor and calls setPointerCapture so we keep receiving pointermove even when the pointer leaves the grid. When the pointer is near an edge, a timer scrolls the grid and keeps extending, so the user can drag-select 500 rows. On pointerup we release capture and stop the timer.

The fill handle is the small square at the bottom right of the selection. Dragging it repeats the selection into new cells: a single value is copied, a pattern like 1, 2 continues as 3, 4, 5, a date steps by a day, and formulas are copied with their relative references shifted (=A1*2 filled down becomes =A2*2, =A3*2). Fill is a single document edit that writes many cells, so it undoes in one step.

Check your understanding · Exercise 4

The user clicks C5, then presses Shift+Down three times, then Shift+Up once. Which statements are true? Select all that apply.

Editing a cell: modes, the editor overlay, and IME

Editing has to feel like a desktop app, and the same key means different things depending on what the user is doing. Arrow keys move the selection while navigating, move the text cursor while editing text, and pick a cell reference while typing a formula. So editing is a state machine, not a boolean isEditing flag.

  • Navigating: the grid has focus; keys move and extend the selection.
  • Quick entry: the user started typing on a cell. The old content is replaced. Arrow keys commit the value and move, so you can type a column of numbers with 5, Down, 7, Down.
  • Full edit: entered with F2, Enter, or a double-click. The old content is kept, and arrow keys move the text cursor inside it.
  • Pointing: while typing a formula, right after an operator or (, arrow keys and clicks insert a cell reference such as B7 instead of moving the text cursor. Excel and Google Sheets both behave this way.
State machine for editing. From Navigating, typing a key goes to Quick entry, where arrow keys commit. F2, Enter, or a double-click goes to Full edit, where arrow keys move the text caret. F2 switches from Quick entry to Full edit. In a formula, after an open parenthesis or an operator, Quick entry goes to Pointing, where arrow keys pick a cell reference. Enter or Tab commits and Escape cancels, returning to Navigating.Navigatingarrows selectQuick entryarrows commitFull editarrows move caretPointingarrows pick a reftype a keyF2, Enter, double-clickF2after ( or +Enter, Tab: commitEsc: cancelback to Navigating
The same arrow key means four different things, so editing is a state machine rather than a boolean. Every state returns to Navigating on Enter, Tab, or Escape.

The editor overlay

On a canvas grid, the cell you edit is a real DOM textarea placed exactly over the cell, with the cell's font, alignment, and background. It grows right and down as you type, and it can cover neighbor cells while editing. When the user commits, the overlay hides and the canvas paints the new value.

There is a subtle trap. In quick entry, the first key press must already land in a text field. If the grid has focus and we only create the editor on keydown, the first character can be lost, and the first key of an IME composition (the input method used to type Chinese, Japanese, Korean, and many other languages, where several key presses build one character) goes to the wrong element.

The common fix: keep one hidden but focusable textarea always focused while navigating. Keys that are commands (arrows, Ctrl+C) are handled on it; anything that produces text simply goes into it, and at that moment we reveal it over the active cell. The browser never sees a focus change in the middle of typing. (The accessibility chapter adjusts this while a screen reader is in use.)

  • During composition, keydown events have event.isComposing === true. Ignore our shortcuts then: Enter confirms the IME candidate, it must not commit the cell. Safari fires compositionend before that Enter keydown, so also ignore a keydown whose keyCode is 229, as the rich text editor guide explains.
  • Listen to compositionstart and compositionend to know when the draft text is final.
  • Let the browser handle undo inside the editor (Ctrl+Z while editing undoes typing); our document undo starts only after the commit.

The formula bar

The formula bar above the grid shows the raw content of the active cell: the formula, not its result. It is a second editor for the same draft. Both editors bind to one draft value in the editing store, so typing in one updates the other. When the user types a formula, both color each reference (B2 blue, C3:C9 red) and the grid draws matching colored boxes around those cells, which is the most helpful thing a spreadsheet does for formula authors.

Committing an edit

On Enter, the raw text becomes a typed value. = starts a formula, which is parsed (next chapter). A leading apostrophe forces text, so '007 stays 007. Otherwise we try number, percentage, date, and boolean, using the file's locale: 1/2 is January 2 in the US, February 1 in the UK, and 1,5 is one and a half in Germany.

The typed value is then applied locally right away (an optimistic update), sent to the server as an operation, and passed to the formula engine to recalculate dependents.

Sequence for committing an edit, between the User, the Editor, the Commands layer, the Worker, and the Server. The user types 120 and presses Enter. The editor commits B5 to the command layer, which paints B5 in the next frame, sends the operation to the worker, and sends it to the server. The worker replies with new values. The server acks with version 4181. On the failure path, the server rejects the operation because the range is protected, and the command layer rolls back B5 and shows why.UserEditorCommandsWorkerServer120, Entercommit B5paint B5apply opsend opnew valuesack v4181rejectroll back B5
The user sees their value in the next frame, before the engine or the network answer. A rejected operation is rolled back by applying its inverse, the same mechanism undo uses.

The sequence shows why the user never waits for the network or the engine: the typed value appears in the next frame, the engine result arrives a few milliseconds later, and the server's acknowledgment arrives within a round trip. If the server rejects the edit (the user lost edit access, or the range is protected), the client rolls back that one operation and shows a message. Rollback is easy because the change is an operation with a known inverse, which we also need for undo.

Check your understanding · Exercise 5

A Japanese user types a cell value with an IME and presses Enter to pick a kanji candidate. The cell commits early with half-typed text. Which fixes help? Select all that apply.

The formula engine: parse, link, recalculate

The formula engine is the core of a spreadsheet and the part most candidates skip. It has three jobs: turn formula text into something it can run, know which cells depend on which, and after an edit, recalculate exactly the cells that need it, in an order where every input is ready before it is read.

Step 1: parse formulas into a tree

Running =SUM(B2:B9) * 1.2 + C1 by searching the string each time would be slow and fragile. Instead, a tokenizer splits the text into tokens (SUM, (, B2:B9, ), *, 1.2...), and a parser turns the tokens into an abstract syntax tree (AST): a tree where each node is an operation and its children are its inputs. Operator precedence (* before +) is decided once, during parsing. A small hand-written recursive descent or Pratt parser is enough: spreadsheet grammar is tiny compared to a programming language.

ts
type Ref = { sheet?: string; row: number; col: number; rowAbs: boolean; colAbs: boolean };

type Node =
  | { kind: "number"; value: number }
  | { kind: "string"; value: string }
  | { kind: "ref"; ref: Ref }                      // B2, $B$2, 'Q1 Budget'!B2
  | { kind: "range"; from: Ref; to: Ref }          // B2:B9, or B:B for a whole column
  | { kind: "binary"; op: "+" | "-" | "*" | "/" | "^" | "&" | "=" | "<"; left: Node; right: Node }
  | { kind: "call"; name: string; args: Node[] };  // SUM(...), IF(...), VLOOKUP(...)
AST node types. References are stored relative to the formula's own cell unless marked absolute with $.
The abstract syntax tree of the formula =SUM(B2:B9) * 1.2 + C1. The root is a plus node. Its left child is a times node and its right child is a reference to C1. The times node has two children: a call to SUM and the number 1.2. The SUM call has one child, the range B2 to B9.=SUM(B2:B9) * 1.2 + C1+*C1ref, relativeSUM(...)1.2B2:B9* before +: decided onceB2:B9 is one range noderefs: offsets from host
Operator precedence is decided once, at parse time: * sits below +. The range is one node, which the dependency graph stores as one rectangle, not eight edges.

A neat trick: store references as offsets from the formula's own cell, not as fixed addresses. =A1*2 in B1 becomes "the cell one column to my left, same row, times 2". Fill it down 10,000 rows and every copy has the same relative form, so all of them can share one parsed tree.

Excel's file format does something similar with "shared formulas". Only absolute parts ($A$1) stay fixed. This also makes copy, paste, and fill almost free, as we'll see in the clipboard chapter.

Step 2: the dependency graph

After parsing, we walk the tree and collect every reference: the formula's precedents (the cells it reads). We store the reverse direction too: for each cell, its dependents (the formulas that read it). Together they form a directed graph. When B5 changes, we follow the dependents edges to find every formula that might need a new value.

Ranges need care. =SUM(B2:B9000) reads 8,999 cells. Adding 8,999 edges for one formula is wasteful, and 1,000 such formulas would create about 9 million edges.

Instead, keep range dependencies in a separate index of rectangles (per column interval lists, or a spatial index such as an R-tree). When B5 changes, we ask that index "which ranges contain B5?" and get the formulas back. Single-cell references stay as plain edges.

Decision After an edit, which formulas do we recalculate, and in what order?

Recalculate everything

Evaluate every formula in the file, repeating passes until nothing changes.

Pros

  • Simple; no graph to maintain.

Cons

  • With 1 million formulas, even 1 microsecond each is a full second per key press.
  • Repeated passes can evaluate one cell many times, and the order is wrong on the first pass.

Dirty marking plus topological order

From the changed cells, walk dependents to mark every downstream formula dirty.

Evaluate the dirty set in topological order: a formula runs only after all its dirty inputs have new values.

Pros

  • Each affected formula runs exactly once.
  • Work is proportional to what the edit touches, not the sheet size.
  • Cycle detection comes with it: cells that never become ready are in a cycle.

Cons

  • We must keep the graph correct through every edit, insert, and delete.
  • Dynamic references like INDIRECT hide their inputs until they run.

Lazy, pull on read

Mark dirty cells, but compute a value only when something reads it: the screen, a chart, or another formula.

Pros

  • Formulas far off screen cost nothing until someone looks.

Cons

  • Scrolling can trigger large computations in the middle of a frame.
  • Collaborators and exports still need correct values, so the work happens anyway, at a worse time.

What we pick: Use dirty marking plus topological order as the default, running in a worker. It is correct, predictable, and does the least work that still keeps every value current. Add a small lazy touch on top: evaluate dirty cells in the visible window first, so the screen updates before the far-off cells finish.

Step 3: a tiny recalculation engine

Here is the core, small enough to write in an interview. It uses Kahn's algorithm: count how many dirty inputs each dirty cell waits for (its in-degree), start with those waiting for none, and as each cell is computed, tell its dependents one fewer input is pending. Any dirty cell that never reaches zero is part of a cycle, or depends on one.

ts
function recalc(changed: string[]): string[] {
  const dirty = new Set<string>();
  const stack = [...changed];
  while (stack.length) {                          // 1. mark everything downstream
    const c = stack.pop()!;
    for (const d of dependents.get(c) ?? []) {
      if (!dirty.has(d)) { dirty.add(d); stack.push(d); }
    }
  }
  const waiting = new Map<string, number>();
  for (const c of dirty) {
    waiting.set(c, formulas.get(c)!.deps.filter((d) => dirty.has(d)).length);
  }
  const ready = [...dirty].filter((c) => waiting.get(c) === 0);
  const done = new Set<string>();
  while (ready.length) {                          // 2. Kahn's algorithm
    const c = ready.shift()!;
    values.set(c, formulas.get(c)!.fn((ref) => values.get(ref) ?? 0));
    done.add(c);
    for (const d of dependents.get(c) ?? []) {
      if (!dirty.has(d)) continue;
      waiting.set(d, waiting.get(d)! - 1);
      if (waiting.get(d) === 0) ready.push(d);
    }
  }
  for (const c of dirty) if (!done.has(c)) values.set(c, "#CYCLE!"); // 3. never ready
  return [...done];
}
Mark downstream cells dirty, evaluate in topological order, and flag cycles. Tested in Node below.
js
// setFormula(cell, deps, fn) stores the formula, adds the dependents edges, and computes it once.
test("diamond: each cell once, D1 last", () => {
  values.set("A1", 2);
  setFormula("B1", ["A1"], (g) => g("A1") * 10);
  setFormula("C1", ["A1"], (g) => g("A1") + 1);
  setFormula("D1", ["B1", "C1"], (g) => g("B1") + g("C1"));
  values.set("A1", 5);
  const order = recalc(["A1"]);
  assert.equal(order.length, 3);
  assert.equal(order.at(-1), "D1");
  assert.equal(values.get("D1"), 56);            // 50 + 6
});

test("cycle: E1 and F1, and G1 downstream, are flagged", () => {
  setFormula("E1", ["F1", "A1"], (g) => g("F1") + g("A1"));
  setFormula("F1", ["E1"], (g) => g("E1") + 1);
  setFormula("G1", ["E1"], (g) => g("E1") * 2);
  recalc(["A1"]);
  assert.equal(values.get("E1"), "#CYCLE!");
  assert.equal(values.get("G1"), "#CYCLE!");
  assert.equal(values.get("D1"), 56);            // unaffected by the cycle
});
node --test engine.test.mjs: a diamond (D1 reads B1 and C1, both read A1) and a cycle (E1 and F1 read each other).

Change the inputs in the lab below and watch the order of evaluation. Then close a loop and see which cells the engine refuses to compute.

Dependency graph of six cells. A1 feeds B1 and B2, A2 feeds B2, B1 and B2 feed C1, and C1 feeds C2. Values: A1 3, A2 4, B1 6, B2 7, C1 13, C2 130. Last change: A1. Evaluation order: none.A1input3A2input4B1=A1*26B2=A1+A27C1=B1+B213C2=C1*10130
Change A1 or A2. Then close the loop and see what the engine refuses to compute.

Circular references, volatile functions, and dynamic references

  • Circular references. Google Sheets shows a #REF! error with the message "Circular dependency detected"; Excel shows a warning. Both offer an opt-in iterative calculation setting that re-runs the cycle up to a maximum number of times or until values change by less than a threshold. Our engine supports that by evaluating the leftover cells in a bounded loop when the setting is on.
  • Volatile functions. NOW(), TODAY(), RAND(), and RANDBETWEEN() have no cell inputs but change anyway. The engine keeps a set of volatile cells and marks them dirty on every recalculation. Spreadsheets also let editors control timing: Google Sheets has offered recalculating every minute or every hour on top of every change, which our engine handles with a timer that posts a tick message. Since September 2026 it also has a manual mode that waits until the user asks, which for us means holding the dirty set until then.
  • Dynamic references. INDIRECT("B" & A1) builds a reference from text, so its inputs are only known after it runs. The safe choice is to treat such cells as volatile; Excel does the same for INDIRECT and OFFSET. A finer option records the inputs found during each run and updates the graph.
  • Errors propagate. #DIV/0!, #VALUE!, #REF!, #NAME?, and #N/A are values, not exceptions. A formula that reads an error returns that error unless it handles it, as IFERROR does.

Check your understanding · Exercise 6

A1 is 3. B1 = A1*2, C1 = A1+B1, D1 = SUM(B1:C1). The user types 4 into A1. Which statements are true? Select all that apply.

Where the engine runs: main thread, worker, or server

Our engine is correct, but correct is not enough. A user pastes 50,000 rows of prices into a column that 200,000 formulas read. Recalculation takes 2 seconds.

If it runs on the main thread, the page is frozen for those 2 seconds: no scrolling, no typing, the cursor stops blinking. The browser architecture lesson explains why: the main thread runs JavaScript, style, layout, and input handling one task at a time, so one long task blocks everything else.

Decision Where should formulas be calculated?

On the main thread

Call the engine directly from the edit handler; maybe slice the work into chunks with setTimeout or scheduler.yield().

Pros

  • Simplest; the engine reads the same cell store the renderer uses.

Cons

  • Every heavy recalculation fights with scrolling and typing.
  • Slicing helps responsiveness but makes total time longer and the code harder to reason about.

In a Web Worker on the client

The engine and its own copy of the cell data live in a dedicated worker.

The main thread posts edits; the worker recalculates and posts back only the changed values.

Pros

  • The main thread stays free: scrolling and typing never wait for formulas.
  • Results come back as fast as the CPU allows, with no network round trip.
  • Works offline.

Cons

  • Two copies of the data (UI and engine) to keep in sync, and the memory that costs.
  • Messages are copied with structured clone, so the protocol must send small diffs, not whole sheets.

On the server only

The client sends edits; the server calculates and sends back values.

Pros

  • One engine, one source of truth; the client stays thin.
  • Needed anyway for API access, scheduled recalculation, and functions that fetch outside data.

Cons

  • Every formula result waits for a round trip: 100 ms or more, felt on every edit.
  • No offline formulas.

What we pick: Run the engine in a Web Worker on the client, and keep a server engine too, using the same code. The client worker gives instant results; the server one calculates for API reads, for external data functions, and stores computed values so a freshly opened file can paint before the client engine has loaded. This matches what Google has published about Sheets: its web.dev case study says calculation moved from the server into the browser in 2013, runs in a Web Worker, and talks to the main thread over a MessageChannel.

Two timelines. With the engine on the main thread, a key press is followed by a 2,000 ms recalculation, and the next key press waits until it ends. With the engine in a worker, the main thread keeps handling typing and drawing frames while the worker runs the same 2,000 ms recalculation and then posts new values.engine on the main threadmaintyperecalc: 2,000 mstypewaitedengine in a workermaintypedrawtypedrawdrawtypedrawworkerrecalc: 2,000 msvaluesframes keep coming while the worker runs
The work is the same size in both. What changes is who waits: on the main thread, the user does; in a worker, only the formula results do.

Moving the engine doesn't make recalculation faster; it makes it harmless. The worker still needs 2 seconds for that paste, but during those 2 seconds the main thread keeps drawing frames and taking key presses. The cost is a second copy of the data and a message protocol between the two sides, which is the next thing to design.

The protocol between the UI and the worker

ts
// main thread -> worker
type ToEngine =
  | { type: "load"; sheetId: string; chunk: ArrayBuffer }        // transferred, not copied
  | { type: "apply"; ops: Op[]; version: number }                // local or remote edits
  | { type: "viewport"; sheetId: string; rows: [number, number]; cols: [number, number] }
  | { type: "tick" };                                            // re-run volatile cells

// worker -> main thread
type FromEngine =
  | { type: "values"; version: number; cells: [row: number, col: number, value: CellValue][]; final: boolean }
  | { type: "error"; version: number; message: string };
Messages are plain objects. The worker sends visible cells first so the screen updates before far-off cells.
  • Small diffs, not snapshots. Structured clone copies every object, so sending a whole sheet would cost tens of milliseconds. Send only the changed cells. For loading, pass ArrayBuffer chunks in the transfer list, which moves them without copying.
  • Viewport first. The worker knows the visible window and evaluates dirty cells there first, then posts final: false. It finishes the rest in the background and posts final: true.
  • Versions. Every batch of edits has a version number. If the user types again while a long recalculation runs, the worker can stop early, apply the new edit, and continue; the UI ignores results for older versions.
  • Stale markers. While a cell's new value is pending, the UI keeps showing the old one. For long runs (over about 500 ms), a small progress bar tells the user the sheet is still calculating.

Think first

Why not share the cell data between the main thread and the worker with a SharedArrayBuffer, instead of keeping two copies?

Check your understanding · Exercise 7

The engine runs in a worker. A user types into A1 while a 2-second recalculation from a paste is still running. What should happen? Select all that apply.

The data model: sparse cells, chunks, and formats

The naive data model is a 2D array: cells[row][col]. For our heavy sheet that is 5 million slots, most of them empty, because real sheets are sparse: a column of 100,000 dates next to mostly blank space. Worse, a JavaScript object per cell costs tens of bytes before it holds any data. At 20 million cells, objects alone would use more memory than a browser tab can comfortably hold.

Three ideas fix this. Store only cells that have something in them (sparse storage). Group cells into chunks of rows that load, cache, and unload together. And separate what a cell is (its raw input), what it shows (its computed value), and how it looks (its format), because each changes for different reasons and lives in different places.

Decision How do we store cells in memory on the client?

Dense 2D array of cell objects

cells[row][col] = { value, formula, format } for every cell in the used area.

Pros

  • Easiest to read and debug.

Cons

  • Memory grows with the rectangle, not with the data.
  • Millions of small objects slow down garbage collection, which shows up as frame drops.

One big map keyed by address

Map from "row:col" (or row * 100000 + col) to a cell object.

Pros

  • Sparse: empty cells cost nothing.
  • Simple random access.

Cons

  • No locality: drawing a screen or loading a region does thousands of scattered lookups.
  • Can't load or evict a region as a unit, and "next filled cell in this column" needs a scan.

Row chunks with columnar, sparse storage inside

A sheet is split into chunks of, say, 1,000 rows. Each chunk stores, per column, the sorted list of filled rows and their values in typed arrays and a string table.

Formats are interned: each distinct format is stored once in a style table and cells point to it with a small number.

Pros

  • Memory tracks real data; typed arrays avoid millions of objects.
  • A chunk is the unit of loading, caching in IndexedDB, and eviction.
  • Sorted filled rows per column make Ctrl+Down and range sums fast.

Cons

  • More code: inserting rows shifts data across chunk boundaries.
  • Harder to inspect in devtools.

What we pick: Use row chunks with columnar sparse storage. It matches how users work (they look at a band of rows at a time), it lets us stream and evict data per chunk, and it keeps a 20-million-cell file within memory. Hide the chunking behind a small CellStore interface (get, set, nextFilled, scan) so the renderer and the engine never see it.

ts
type CellValue = number | string | boolean | { error: "#DIV/0!" | "#REF!" | "#VALUE!" | "#NAME?" | "#N/A" };

type Cell = {
  input?: string;      // what the user typed: "=SUM(B2:B9)", "42", "'007"
  value: CellValue;    // computed by the engine; what the renderer shows
  styleId: number;     // index into the shared style table (0 = default)
};

type Style = { numberFormat?: string; bold?: boolean; fill?: string; align?: "left" | "center" | "right"; wrap?: boolean };

type Sheet = {
  id: string; name: string; index: number;
  rowCount: number; colCount: number;
  frozen: { rows: number; cols: number };
  rowSizes: Map<number, number>;  colSizes: Map<number, number>;  // only non-default sizes
  merges: Range[];
  chunks: Map<number, Chunk | "loading">;   // chunk index -> data, loaded on demand
};

type Workbook = { id: string; title: string; version: number; sheets: Sheet[]; styles: Style[]; locale: string; timeZone: string };
Client-side entities. Raw input, computed value, and format are separate fields with separate owners.
The client data model. A Workbook with version, locale, and time zone has a list of Sheets and a shared Style table. Each Sheet holds sizes, frozen panes, and merges, and its cells in Chunks of 1,000 rows stored by column. Each Cell in a chunk has its input, computed value, and a style id that points into the Style table. The worker keeps its own copy of formulas, the dependency graph, and the range index.Workbookversion, locale, tzSheetsizes, frozen, mergesChunk1,000 rows, columnarCellinput, value, styleIdStyle tableinterned formatssheetschunks by indexstylesstyleIdworker keeps:formulas, graphrange index
Raw input, computed value, and format are three separate fields. Styles are stored once and shared by id, and chunks are the unit of loading, caching, and eviction.

Where each piece of data lives

DataLives inWhy there
Workbook, sheets, cells, styles (the truth)Server, as a snapshot plus an operation logShared by all editors; the server orders edits and stores history
Loaded chunks: inputs, values, style idsMain thread CellStore, plus a copy in IndexedDBThe renderer reads it every frame; IndexedDB makes reopening fast and survives a reload
Formulas, ASTs, dependency graph, range indexThe engine workerOnly the engine needs them; keeping them off the main thread saves its memory and time
Unsent local edits (the outbox)Memory and IndexedDBMust survive a refresh or a dropped connection, and be replayed in order
Selection, editing draft, scroll positionUI state on the main threadLocal, changes many times per second, never stored in the document
Spreadsheet id, active sheet, linked rangeURLShareable and bookmarkable; Google Sheets puts the sheet in #gid= and can link a range
Other editors' selectionsPresence store, from the socketLossy and short-lived; only the latest position matters

Chunks far from the viewport are evicted from memory with a least-recently-used rule once the store passes a budget (say 200 MB), because a long session that scrolls through a big file would otherwise keep every chunk forever. The memory leaks lesson covers the other usual suspects in a long-lived app like this one: listeners left on removed overlays, and caches with no limit, such as the text measurement cache.

Check your understanding · Exercise 8

Which statements about this data model are true? Select all that apply.

Architecture: the pieces and how data flows

We now have enough parts to draw the whole client. The guiding rule: the main thread only does what must happen within a frame (input, drawing, the few DOM overlays), and everything heavy or slow (formulas, parsing large files, the network) happens somewhere else and reports back with small messages.

Component architecture. On the main thread: the App shell (toolbar, formula bar) writes the edit draft to the UI stores (selection, draft). The Grid view (canvas and overlays) sends intents to the Command layer (operations and undo stack), which applies operations to the Cell store (chunks and values), sends them to the Sync client (socket and outbox), and sends them to the Engine in a Web Worker (graph, recalculation). The Loader in the worker fetches chunks from the Sheets server. The Sync client talks to the Sheets server over a WebSocket. The Cell store caches chunks in IndexedDB, and the Sync client keeps its outbox there.main threadApp shelltoolbar, formula barGrid viewcanvas, overlaysUI storesselection, draftCommand layerops, undo stackCell storechunks, valuesSync clientsocket, outboxWeb WorkerEnginegraph, recalcLoaderfetch, decodeSheets serverorders opsIndexedDBdraftintentsopscacheoutbox
The command layer is the only door for changes, so undo, sync, and the engine all see the same operations. Heavy work (formulas, decoding chunks) lives in the worker; the engine posts changed values back to the cell store, a path the data flow diagram shows.
PartResponsibilityWhy the boundary is here
App shell (React)Toolbar, menus, formula bar, sheet tabs, dialogsNormal UI that changes rarely; a framework fits it well
Grid viewCanvas layers, scroll container, editor overlay, fill handle, hit testingHot path: runs every frame, so it reads plain stores directly and never waits on React renders
Selection and editing storeSelection, active cell, edit mode, draft textLocal UI state shared by grid, formula bar, and toolbar
Command layerTurns user intent (type, paste, insert row, bold) into operations; owns undo and redo stacksOne place that creates every change, so undo, sync, and the engine all see the same operations
Cell storeLoaded chunks with display values and styles; answers get and nextFilledThe renderer's only data source; hides chunking and loading
Engine workerFormulas, dependency graph, recalculationHeavy CPU work, kept off the main thread
Sync clientWebSocket, outbox, rebasing local edits on remote ones, presenceNetwork concerns isolated from the rest; can reconnect and replay without the UI knowing
LoaderFetches metadata and chunks, streams and decodes them in the worker, hands display data to the cell storeLarge file handling in one place, with its own priorities

Two boundaries deserve a sentence each in the interview. The command layer is the single door for changes: the keyboard, the toolbar, paste, fill, and remote edits all become the same Op objects. That's what makes undo, collaboration, and recalculation consistent, because they all speak one language.

The grid view stays outside React's render cycle: React draws the toolbar and panels, but the canvas is drawn by a plain loop subscribed to the stores. A React re-render on every scroll frame or arrow key would break the 8 ms budget.

Data flow, end to end

Follow one edit around the loop. The user types 120 into B5 and presses Enter. The command layer creates setCell(sheet1, B5, "120"), applies it to the cell store (the grid repaints B5 next frame), pushes its inverse on the undo stack, sends it to the worker, and adds it to the outbox.

The worker recalculates and posts changed values, which update the cell store and repaint again. The sync client sends the outbox over the WebSocket; the server assigns the operation a version number, stores it, and broadcasts it to other editors, whose sync clients apply it through their own command layer.

Data flow for an edit. The Editor sends setCell to the Command layer, which sends the op to the Sync client, which sends ops with a sequence number to the Server. The Server sends remote ops back to the Sync client, which hands them to the Command layer. The Command layer applies ops to the Cell store and sends them to the Engine worker, which returns changed values to the Cell store. The Cell store gives the visible cells to the Grid canvas, which paints the next frame.EditorCommandsSyncServerGrid canvasCell storeEnginesetCellopops, seqremote ops come backapplyopvaluesvisible cellspaints next frame
Local and remote edits meet in the command layer and take the same path from there. What crosses the network is small operations like setCell, never whole sheets; computed values are made on each client by its own engine.

Opening a file flows the other way: the shell loads, the loader fetches the workbook metadata and the chunks around the saved position, the cell store fills, and the grid paints computed values that the server stored. The worker loads formulas in the background and takes over recalculation once it is ready. The user can scroll and read long before the engine has finished loading.

Check your understanding · Exercise 9

Why does every change, local or remote, go through the command layer as an operation? Select all that apply.

Copy, paste, and undo

Copy and paste is how data gets into spreadsheets. Users paste from Excel, from web pages, from other sheets, and from your own app. Undo is how they recover when that goes wrong. Both are easy to build badly and hard to build well.

Copy: write several formats at once

On copy, we handle the copy event (fired for Ctrl+C and the browser's Edit menu) and call event.clipboardData.setData for two formats. Each target app picks the richest format it understands:

  • text/plain as TSV (tab-separated values): cells joined by tabs, rows by newlines. A value that contains a tab, newline, or quote is wrapped in double quotes, with inner quotes doubled, the same way Excel writes it. Every text editor and spreadsheet can read this.
  • text/html as a table, with formats (bold, colors, number formats) as inline styles. Excel, Google Docs, and email clients keep the formatting.
  • Our own app also needs the formulas, not just the values. Spreadsheets commonly embed extra metadata in the copied HTML, so a paste back into the app can restore formulas and adjust them. Google Sheets' copied HTML, for example, has been seen to carry attributes such as data-sheets-value and data-sheets-formula on its cells; that format is undocumented and can change, so we design our own (a data attribute holding the source formulas and the source address).

Paste: read the richest format, safely

On paste, read text/html first, and if it contains our metadata, use the formulas. If it is a table from another app, parse it with DOMParser (which builds a document without running scripts or loading images) and read only cell text and a few safe styles. Never insert pasted HTML into the page. If there is no HTML, parse text/plain as TSV.

Large pastes (say 50,000 rows) must not freeze the grid. TSV text can be parsed in the worker. DOMParser doesn't exist in workers, so an HTML table is parsed on the main thread, and its cell text is pulled out in slices that yield between them. Either way, the result is applied as one operation, so one undo removes the whole paste.

Relative references move with the paste

Copy B2 containing =A1*2 and paste it into D5. The paste moved 3 rows down and 2 columns right, so every relative reference moves by the same amount: the result is =C4*2. Absolute parts stay: =$A$1*2 stays =$A$1*2, and the mixed =$A1*2 becomes =$A4*2.

If a reference would land off the sheet (pasting =A1 one row up from row 1), it becomes #REF!. Because we store references as offsets from the host cell (the formula engine chapter), copying a formula is just copying its relative form; nothing is rewritten until we print it as text.

Copying B2, which holds =A1*2, and pasting it into D5. B2 points to the cell one row up and one column left, A1. The paste moves 3 rows down and 2 columns right, so the pasted formula in D5 points one row up and one column left of D5: =C4*2. Notes: =$A$1*2 stays =$A$1*2, and =$A1*2 becomes =$A4*2. A reference pasted above row 1 becomes #REF!.A1=A1*2in B2C4=C4*2in D5+3 rows, +2 columns=$A$1*2 stays =$A$1*2=$A1*2 becomes =$A4*2=A1 pasted above row 1becomes #REF!
The formula is stored as offsets from its own cell, so the copy keeps the same shape. Dollar signs pin a row or column: $A$1 never moves, $A1 keeps its column.

Cut and paste is different from copy and paste. A cut moves cells, and formulas elsewhere that pointed at the moved cells follow them: if E1 = B2 and you cut B2 to D5, E1 becomes =D5. A copy leaves other formulas alone. Interviewers like this distinction because it shows you understand references as links, not text.

Undo and redo

Decision How should undo work in a shared spreadsheet?

Snapshots

Save a copy of the changed area (or the whole sheet) before each edit; undo restores it.

Pros

  • Simple and always exact for a single user.

Cons

  • Memory heavy for big pastes.
  • In a shared sheet, restoring a snapshot wipes out edits other people made since, which is a lost-edit bug.

Inverse operations, per user

Each operation carries its inverse: setCell(B5, "120") stores the old input to put back.

Undo applies the inverse of the user's own last operation, after transforming it against the remote operations that came after it.

Pros

  • Undo touches only what you changed, so collaborators' work survives.
  • Undo is just another operation, so it syncs, recalculates, and can be redone like any edit.

Cons

  • Transforming inverses through later structure changes (rows inserted above) needs the same logic as collaboration.
  • If someone else overwrote the same cell, undo has to choose: skip, or overwrite them back.

What we pick: Use per-user inverse operations. In a shared document, undo must mean "undo my change", never "go back in time for everyone". Group operations that the user sees as one action (a paste, a fill, a sort) so they undo in one step, restore the selection along with the data, and cap the stack by memory (say 100 steps or about 50 MB of stored old values), since the inverse of a large paste holds all the overwritten cells.

Check your understanding · Exercise 10

C3 contains =A1+$B$1+B$2. You copy C3 and paste it into E7. What is the pasted formula, and what else is true? Select all that apply.

Collaboration: merging edits cell by cell

The Google Docs guide covers the collaboration infrastructure: the WebSocket, the server that orders operations, and the client that keeps a queue of pending edits. A spreadsheet reuses all of that. What changes is the shape of the conflicts, and they are in some ways easier and in some ways harder than in text.

Easier: most edits touch different cells, and two edits to different cells never conflict. Two people filling in different rows of a budget can just both apply.

Harder: structure changes (insert or delete rows and columns, sort, move) shift the address of every cell below or to the right, and every formula pointing there. If Ana inserts a row above row 10 at the same moment Ben types into A12, Ben's edit was meant for the row that is now row 13. Applied blindly, it lands in the wrong row on Ana's screen.

Decision How do we merge concurrent edits?

Server-ordered operations: last writer wins per cell, transforms for structure

The server gives every operation a version number, which defines one global order.

Two writes to the same cell: the later one in server order wins, on every client.

Structure operations transform the addresses in other operations: an insert at row 10 shifts a concurrent setCell(A12) to A13.

Pros

  • Transforms are 1D index shifts, far simpler than operational transformation (OT) on text.
  • Fits the central server we need anyway for permissions and history.
  • Every client ends with the same state.

Cons

  • Needs a connected server to order edits; offline edits are rebased later.
  • Same-cell conflicts silently keep one value; the other person's typing is overwritten.

Stable row and column ids

Each row and column gets a permanent id at creation. Operations address (rowId, colId), not (12, 1).

Row order is a list of ids, kept with a sequence CRDT (a conflict-free replicated data type, which merges edits in any order) or server ordering.

Pros

  • Inserts never change a cell's address, so cell edits need no transforms.
  • Works well for peer-to-peer or offline-heavy designs.

Cons

  • Every lookup by index (drawing row 5,000, Ctrl+Down) needs an id-to-index map kept in sync.
  • Ids cost memory per row and column, and formulas still show positions, so references must map both ways.

Full CRDT document

Model the whole workbook as CRDT maps and sequences (the CRDTs lesson covers the types); merges happen on any client in any order.

Pros

  • No central ordering needed; strong offline story.

Cons

  • Metadata overhead per cell at 20 million cells is large.
  • Spreadsheet rules (formula rewriting on insert, #REF! on delete) still need custom logic on top.

What we pick: Use server-ordered operations: last writer wins on a single cell, and simple index transforms for structure changes. We already need a server for access control and version history, cell conflicts are rare and easy to explain, and the transforms are small enough to test exhaustively. Mention stable ids as the alternative if the interviewer pushes on offline-first editing.

ts
type Op =
  | { type: "setCell"; sheet: string; row: number; col: number; input: string }
  | { type: "insertRows"; sheet: string; at: number; count: number }
  | { type: "deleteRows"; sheet: string; at: number; count: number };

function transformCell(op: Extract<Op, { type: "setCell" }>, prior: Op): Op | null {
  if (prior.sheet !== op.sheet || prior.type === "setCell") return op;
  if (prior.type === "insertRows") {
    return op.row >= prior.at ? { ...op, row: op.row + prior.count } : op;
  }
  // deleteRows
  if (op.row < prior.at) return op;
  if (op.row < prior.at + prior.count) return null; // the row is gone: drop the edit
  return { ...op, row: op.row - prior.count };
}
Transform a cell edit against a concurrent row change that the server ordered first. Columns work the same way.

Formulas need the same shift. When an insertRows arrives, every client's engine rewrites references at or below the insert point (=SUM(B2:B20) with a row inserted at 10 becomes =SUM(B2:B21)), and a delete turns references to removed cells into #REF!. Because each client applies the same operations in the same server order with the same code, all formulas end up identical without sending them.

Sequence between Ana, the Server, and Ben. Ana sends insert row at 10. Ben, who had version 40, sends set A12 to 7. The server orders Ana's insert first as version 41 and acks Ana, and sends Ana's insert to Ben. The server rebases Ben's edit from A12 to A13, acks it to Ben as version 42, and sends set A13 to 7 to Ana. On the failure path, Ben's socket drops while sending set B13 to 9, which stays in his outbox. Ben reconnects with hello, last version 42, and the server sends the missed operations and then acks the replayed edit as version 44.AnaServerBeninsert row at 10set A12 = 7 (base v40)ack v41Ana's insert, v41rebase: A12 to A13ack v42 as A13set A13 = 7, v42set B13 = 9: socket dropshello, last v42 + outboxmissed ops, ack v44
Both clients end with Ben's 7 in A13, the row he meant, because the server orders operations and shifts addresses past inserted rows. A dropped connection loses nothing: the outbox is replayed after the missed operations arrive.

Dropped connections and the outbox

Every local operation sits in the outbox (memory plus IndexedDB) until the server acknowledges it with a version number. When the connection drops, the user keeps editing; operations pile up. On reconnect, the client says "I have everything up to version 4,180". The server sends the operations it missed, the client transforms its outbox against them, and resends.

If the gap is huge (thousands of operations, or someone restructured the sheet heavily), it is faster and safer to fetch a fresh snapshot of the affected chunks and rebase the outbox onto that. If an outbox edit was dropped by a transform (its row was deleted), tell the user rather than losing it silently.

The live protocol over the WebSocket

Live traffic travels on one WebSocket: our operations going up, everyone's operations and presence coming down. The choosing real-time lesson weighs the alternatives. For two-way, frequent, low-latency messages a WebSocket is the standard choice, and Server-Sent Events plus POST would work as a fallback. These are the messages, including the hello that starts the reconnect above.

ts
// client -> server
type Up =
  | { type: "hello"; workbookId: string; lastVersion: number }              // on connect and reconnect
  | { type: "ops"; clientSeq: number; baseVersion: number; ops: Op[] }      // one user action, maybe many ops
  | { type: "presence"; sheetId: string; ranges: Range[]; editing?: CellRef };

// server -> client
type Down =
  | { type: "ack"; clientSeq: number; version: number }                     // your ops are now this version
  | { type: "ops"; version: number; author: string; ops: Op[] }             // someone else's edit, in order
  | { type: "presence"; userId: string; name: string; color: string; sheetId: string; ranges: Range[] }
  | { type: "reject"; clientSeq: number; code: "FORBIDDEN" | "PROTECTED_RANGE" | "INVALID" }
  | { type: "resync"; reason: "TOO_FAR_BEHIND" };                           // fetch fresh chunks
Every client message has a sequence number so acks, retries, and duplicates are unambiguous.
  • baseVersion tells the server which version the client had seen when it made the edit. The server transforms the ops against anything newer before assigning the next version.
  • clientSeq makes retries safe: if the client resends after a reconnect, the server recognizes an already-applied sequence number and just acks it again.
  • reject triggers the rollback path from the editing chapter: undo that one action locally and show why.
  • Batching. A paste of 50,000 cells is one ops message with a compact range op (setRange with a 2D array) rather than 50,000 setCell ops. Keep a size limit (say 1 MB per message) and split above it.

Think first

Should ops messages from the server also carry the new computed values of the formulas they affect?

Presence: seeing other people's selections

Each editor's selection is drawn as a colored box with their name, on the selection canvas layer. Following the presence and cursors lesson, presence travels on the same socket but is not stored: send the active range at most about 10 times per second, drop old updates, and expire a user after a few seconds of silence.

Presence positions also go through transformCell-style shifts when rows are inserted, or the colored box would jump to the wrong row. When someone is editing a cell, show their color on it so others know not to type there; it doesn't lock the cell, it just prevents most same-cell conflicts socially.

Check your understanding · Exercise 11

The server orders Ana's deleteRows(at: 4, count: 2) before Ben's setCell(row: 7) and setCell(row: 5). Rows are numbered from 0. What happens to Ben's edits? Select all that apply.

Opening large files fast, and the loading API

The naive load fetches the whole workbook as one JSON document, parses it, builds the engine, and then shows the grid. For a 50 MB file that means downloading 50 MB, then JSON.parse blocking the main thread for a second or more, then building millions of objects, then calculating. The user stares at a spinner for 10 seconds to see the 600 cells that fit on the screen.

The goal is the opposite order: show the first screen of values quickly, let the user scroll and read, and load everything else behind it in priority order.

Three facts make that possible. The server stores computed values next to inputs, so we can paint without calculating. Chunks let us fetch only the rows the user is looking at. And other sheets are not visible at all until their tab is clicked.

Decision How do we load a large workbook?

One big document

GET /workbooks/:id returns everything; parse, then render.

Pros

  • Simple; one request, one state.

Cons

  • Time to first cell grows with file size.
  • One huge parse blocks the main thread; memory peaks at twice the file size during parsing.

Metadata first, then chunks by priority

Fetch metadata: sheets, sizes, frozen panes, styles, version, and the chunk list.

Fetch the chunks around the saved position of the active sheet, with computed values; paint.

Stream the rest of the active sheet, then formulas for the engine, then other sheets when idle or when opened.

Pros

  • First paint depends on the screen size, not the file size.
  • Parsing happens in small pieces, in the worker.
  • Chunks are natural cache units for IndexedDB.

Cons

  • Scrolling fast into unloaded rows shows placeholder cells for a moment.
  • The engine can't recalculate a formula correctly until the formulas it depends on are loaded.

What we pick: Load metadata first, then chunks by priority. The engine problem has a clean answer: until the engine has loaded the formulas an edit touches, the client shows the edit, sends it, and lets the server compute and return the dependent values. Once the engine is ready, it takes over. Users get a readable sheet in about a second and full speed a few seconds later.

Loading timeline over about 3 seconds, in three lanes. Network: metadata, then the chunk at the saved position, then the rest of the active sheet streamed, then other sheets when idle. Main thread: paint the first screen right after the first chunk, then the user scrolls and reads. Worker: decode the chunk, then load formulas in the background, then the engine is on.netmetachunkrest of sheet 2other sheetsmainpaintscroll and readworkerdecodeload formulasengine on0 s1 s2 s3 s
The first paint waits only for metadata and one chunk with server-computed values. Formulas and other sheets load behind it, and the engine takes over recalculation when it's ready. Times are illustrative.

Streaming and decoding chunks

  • Stream, don't wait. Read response.body as a stream and decode one chunk record at a time (for example newline-delimited records). Cells appear as each record arrives, instead of after the last byte.
  • Decode in the worker. Parsing 10 MB of records is real CPU work. The worker decodes, keeps formulas for itself, and transfers compact display data (typed arrays) to the main thread.
  • Compress on the wire. Spreadsheet data repeats a lot (formats, similar formulas, numbers), so Content-Encoding: br or gzip often shrinks it several times.
  • Cache with versions. Each cached chunk in IndexedDB carries the document version it reflects. On reopen, paint from the cache immediately, then ask the server for operations since that version. A file you opened yesterday opens almost instantly.
  • Placeholders, not blanks. Cells in a chunk that is still loading draw as light gray bars, so the user can tell "loading" from "empty". An empty cell and an unloaded cell must never look the same in a spreadsheet.

Lazy loading other sheets needs one exception: cross-sheet formulas. If Summary!B2 = SUM(Data!C:C), the engine needs the Data sheet's values to recalculate B2, even if the user never opens that tab. The worker loads the inputs of formulas in the background, at low priority, separately from the display chunks. Until then, the server-computed value of B2 is shown and stays correct, because nothing it depends on has changed locally.

The loading API over HTTP

The client talks to the server over two channels. The WebSocket from the collaboration chapter carries the live traffic. Plain HTTP requests load data, because they are cacheable, easy to retry, and can stream. Loading needs two endpoints: metadata, then chunks.

http
GET /api/workbooks/wb_81f/meta
200 OK
{ "id": "wb_81f", "title": "FY26 budget", "version": 4180, "locale": "de_DE", "timeZone": "Europe/Berlin",
  "sheets": [{ "id": "s2", "name": "Data", "rowCount": 100000, "colCount": 50, "chunkRows": 1000,
               "frozen": { "rows": 1, "cols": 1 }, "rowSizes": { "0": 32 }, "colSizes": { "1": 180 } }],
  "styles": [{}, { "bold": true }, { "numberFormat": "#,##0.00" }, { "numberFormat": "yyyy-mm-dd" }] }

GET /api/workbooks/wb_81f/sheets/s2/chunks/52?version=4180
200 OK
Content-Type: application/x-ndjson
Content-Encoding: br
Cache-Control: private, max-age=31536000, immutable
{"row":52000,"cells":[[0,"2025-01-01",45658,3],[1,"=C52001*1.19",1190,2],[2,"1000",1000,2]]}
{"row":52001,"cells":[[0,"2025-01-02",45659,3]]}
Metadata first, then chunks. Chunks are immutable per version, so they cache well.

Each cell in a chunk is a compact array: column, raw input, computed value, style id. Rows here count from 0, so the record for row 52000 is what users see as row 52001.

Tying a chunk URL to a version makes it immutable, so the browser and IndexedDB can cache it forever: a newer version is a different URL. If a chunk is requested for a version the server has compacted away, it answers 410 Gone and the client fetches the latest version of that chunk plus the operations since.

Check your understanding · Exercise 12

A user opens a 40 MB workbook with 6 sheets and a saved position at row 52,000 of sheet 2. What should load before the first paint? Select all that apply.

Numbers, dates, and locales

A spreadsheet is a numbers tool used in every country. The rule that keeps it correct: store raw values, format at the edge. A cell stores the number 1234.5; the format #,##0.00 and the locale decide whether the screen shows 1,234.50 (US English), 1.234,50 (German), or 1 234,50 (French, with a narrow space). The locale formatting lesson covers the Intl APIs in depth; here are the spreadsheet-specific parts.

  • Dates are numbers. Spreadsheets store a date as a serial number of days, with the time as the fraction: Google Sheets counts from December 30, 1899 as day 0, so 45658 is January 1, 2025, and Excel's default date system gives the same serial for any date after early 1900. That's why =B2+7 adds a week and why you can sort dates as numbers. Formatting turns the serial into text with the file's time zone.
  • Locale and time zone belong to the file. Google Sheets has a spreadsheet setting for both, so everyone sees the same dates and NOW() agrees on what "today" is. The viewer's browser locale only affects the menus.
  • Parsing input uses the file's locale. Typing 1/2 means January 2 in a US file and February 1 in a UK file; 1,5 is 1.5 in a German file. The parser has to know which.
  • Formulas are stored in one canonical form. In locales where the comma is the decimal separator, users write function arguments with semicolons: =SUM(A1;B1) and =ROUND(2,5;0). Store the formula once, in a canonical form with . decimals and , separators, and convert to and from the user's locale only in the editor and formula bar. Otherwise a formula typed in Berlin would break when opened in Boston.
  • Cache formatters. Creating an Intl.NumberFormat or Intl.DateTimeFormat is much slower than calling format on one. The renderer keeps one formatter per style and locale and reuses it for every cell in every frame.

Number format patterns such as #,##0.00, 0.0%, $#,##0;($#,##0) (negatives in parentheses), and yyyy-mm-dd are a small language of their own. Parse each pattern once into a tiny formatter object and cache it with the style. Intl handles the locale-specific separators and digit grouping, and the pattern decides the decimals, the percent scaling, and the negative style.

Check your understanding · Exercise 13

A German user types =ROUND(2,5;0) in a file whose locale is German. A US colleague opens the same file. Which statements are true? Select all that apply.

Accessibility and performance budgets

Choosing canvas gave us speed and took away the accessibility the DOM gives by default. A screen reader sees one canvas element with nothing inside. We have to give it back on purpose, and the data table guide's ARIA grid is our starting point: here it becomes a mirror of the canvas, not the thing that draws.

An accessible DOM mirror

  • A hidden ARIA grid. Next to the canvas, render a visually hidden element with role="grid" containing only the rows and columns around the active cell (say 20 by 10). aria-rowcount and aria-colcount give the full sheet size, and aria-rowindex and aria-colindex on each row and cell give their real positions, so a screen reader says "row 52,001 of 100,000" even though only 20 rows exist in the DOM.
  • Cells carry the useful text. Each gridcell has the formatted value, and its accessible description includes the formula when there is one. Column headers come from the A, B, C strip, or from the frozen header row when the user marked one.
  • Focus follows the active cell. The APG grid pattern either moves DOM focus to the active cell, or keeps focus on the grid container and points aria-activedescendant at the active cell. ARIA only allows that pointer to a descendant of the focused element, so our hidden textarea can't use it for cells outside it. A practical compromise: while a screen reader is in use, focus the mirrored active cell while navigating, and move focus into the editor when editing starts (Enter, F2, or a typed character). A polite aria-live region announces things the grid can't express, like "Selected B2 to D9, 24 cells" or "Sum: 1,190".
  • An opt-in mode. Google Sheets has "Turn on screen reader support" and "Turn on braille support" options in its accessibility settings, and Google's help suggests turning braille support off when it isn't needed, because it can slow Sheets down. Building the full mirror only when such a mode is on saves work for everyone else, though the basics (labels, focus, keyboard) should always be on.
  • Forced colors and zoom. Canvas ignores the operating system's high contrast mode, so check matchMedia("(forced-colors: active)") and switch to system colors yourself. In most browsers, page zoom changes devicePixelRatio; redraw at the new ratio so text stays sharp.

The keyboard map from the selection chapter is the other half: every action reachable with the mouse (insert row, resize, fill) must also have a key path, often through the menus, which are normal DOM and follow the ARIA menu patterns.

Performance budgets

A budget turns "fast" into numbers we can test and monitor. The runtime performance lesson explains how frames, long tasks, and INP are measured; these are the spreadsheet's numbers.

InteractionBudgetHow we hold it
Scroll a frameDraw under 8 ms, 60 fps on a mid-range laptopCanvas, position caches, cached text widths and formatters, one draw per requestAnimationFrame
Arrow key to new selectionNext frameRepaint only the selection layer; no React render
Typing a keyNext frame; INP under 200 ms at the 75th percentileReal textarea overlay; no work on input beyond the formula bar mirror
Recalculate 10,000 dependentsVisible results under 100 ms, all under 300 msWorker, topological order, visible cells first
Open a heavy fileFirst cells under 2 s, scrolling right awayMetadata then chunks, server-computed values, IndexedDB cache
Paste 50,000 rowsGrid stays responsive; done in a few secondsTSV parsed in the worker, HTML in slices; one batched op, progress indicator
MemoryHeavy file under about 1 GBSparse columnar chunks, interned styles, LRU eviction

Measure these in the field, not just on a fast developer machine. Canvas content doesn't count as a Largest Contentful Paint candidate, so add a custom mark when the first chunk is painted (performance.mark("first-cells")). Report INP through PerformanceObserver with the Event Timing API, long frames through the Long Animation Frames API in Chromium, and a custom timer from commit to recalculated values. Slice them by file size, because a budget that holds for 1,000 rows and fails for 100,000 is the bug you most need to see.

Check your understanding · Exercise 14

Which choices make the canvas grid usable with a screen reader? Select all that apply.

Running it in the interview

You can't say all of this in 60 minutes, and you shouldn't try. The interviewer is checking whether you find the hard parts, make decisions with reasons, and go deep where it matters. Here is a plan that fits the time.

MinutesWhat to doWhat to draw or say
0 to 5RequirementsSheet size, formulas, collaboration, devices, out of scope. Write the numbers on the board: 20 million cells, 60 fps, 100 to 300 ms recalculation.
5 to 8What makes it hardThe five forces in about a minute; say you'll go deep on rendering, the engine, and collaboration.
8 to 15ArchitectureDraw the architecture diagram: shell, grid view, stores, command layer, worker, sync client, loader. One sentence per boundary.
15 to 25RenderingCanvas plus DOM overlays, two-direction windows, position caches, frozen panes. Mention the scaled scrollbar.
25 to 37Formula engineAST, dependency graph, range index, dirty marking plus topological order, cycles, volatile functions, the worker protocol. Write the recalc loop if asked.
37 to 47CollaborationServer-ordered ops, last writer wins per cell, the insert-row transform, formulas shifting, outbox and reconnect, presence.
47 to 55Data model and APISparse chunks, values vs inputs vs styles, chunk endpoint, socket messages.
55 to 60Wrap upAccessibility mirror, budgets and how you'd measure them, what you'd build next.

Draw the architecture before diving into any part, so every later detail has a place on the board. When you reach a decision, say the options and the trade-off in one or two sentences each, then commit. "DOM would be simpler and more accessible, but at 1,000 visible cells per frame the style and layout cost breaks 60 fps, so I'd draw cells on canvas and keep DOM for the editor and an accessible mirror" is a strong sentence.

Follow-up questions to expect

Think first

"A user sorts a 100,000-row column while a colleague is typing in row 500. What happens?"

Think first

"Recalculation takes 5 seconds on a customer's sheet. How do you find out why and fix it?"

Think first

"Can users edit offline for a whole day?"

Check your understanding · Exercise 15

You have 10 minutes left and haven't discussed collaboration. What is the best use of them? Select all that apply.

Summary

  • A spreadsheet is a renderer, a small programming language, and a shared document at once. Name those three and the forces behind them (size in two directions, live dependencies, desktop speed, sharing, precision) before choosing anything.
  • Draw only the visible window of rows and columns. At spreadsheet scale, paint cells on canvas and keep the DOM for the editor overlay, menus, and an accessible mirror.
  • Position caches (a default size plus a Fenwick tree of differences) answer "where is row r" and "which row is at pixel y" in O(log n), and make hidden rows, resizing, and frozen panes simple.
  • Selection is active cell, anchor, and a list of ranges. Editing is a state machine (navigating, quick entry, full edit, pointing), with a real text field for IME.
  • Parse formulas to ASTs with relative references, keep a dependency graph plus a range index, and recalculate dirty cells once each in topological order; leftovers are cycles. Volatile and dynamic references need special handling.
  • Run the engine in a Web Worker with small diff messages and visible cells first; keep the same engine on the server for API reads and first paint.
  • Store sparse, columnar row chunks with interned styles; keep raw input, computed value, and format separate, and format numbers and dates at render time by the file's locale.
  • Every change is an operation through one command layer, which powers per-user undo, the engine, and sync. Merge with server-ordered ops: last writer wins per cell, index transforms for inserted and deleted rows.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next