SevyDevy

Guide · Frontend

Spreadsheet Frontend Design: Virtualised Canvas, Formula Graphs and Copy-Paste

26 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Spreadsheet frontend design is the work of showing a huge grid smoothly, recalculating formulas in the right order and copying cells out cleanly. Three ideas carry it: draw only what is visible, track which cells read which, and copy in two formats at once.
  • Virtualisation means drawing only the cells on screen, about 518 on a 1280 by 720 pixel view with 20-pixel rows and 100-pixel columns. Paint them onto one canvas, a single bitmap element your code draws on, instead of one HTML element per cell.
  • A dependency graph records which cells read which. When a cell changes, mark it and everything downstream as dirty, meaning stale. Then recalculate in topological order, which simply means every cell after its inputs.
  • Cells that never become ready in that order sit on a circular reference, a loop where a cell ends up reading itself, or depend on one. For copy, put tab-separated text (TSV) and an HTML table on the clipboard, so spreadsheets and document editors both paste well.
  • Beginners can stop after the canvas versus DOM (the browser's tree of page elements) section. Engineers then get tested numbers: Chromium 153 clamped element heights at 33,554,428 pixels. Recalculation that calls itself for each dependent (recursion) crashed on a 5,000-cell chain.

What goes wrong when you render a spreadsheet as one big HTML table?

The page freezes, because the browser builds and lays out a box for every cell. In our headless Chromium test, 260,000 cells (270,000 elements with row wrappers) took 3 to 11.5 seconds to build and lay out. One screen of 518 cells took 4 to 133 milliseconds.

Picture a common setup. A distributor wants its stock sheet in the browser: 2 lakh (200,000) rows, a few formula columns and copy-paste into email. The setup is made up, but these three spreadsheet frontend design bugs are what a first version usually hits.

  • Scrolling stutters or the tab runs out of memory, because every row is a real element.
  • A total cell shows yesterday's number, because formulas were recalculated in the wrong order.
  • Pasting into an email gives one long jumbled line, because only plain text went to the clipboard, with no table.

Spreadsheet frontend design fixes each one with a specific mechanism. This guide builds them one at a time on that stock sheet, with code we ran in Node 22 and facts we checked in Chromium 153 on 27 October 2026.

Which words do you need before designing a spreadsheet frontend?

You need eight words. Each one is tied to the stock sheet.

  • Viewport: the part of the sheet visible on screen right now, such as rows 1 to 36 and columns A to N.
  • DOM: the Document Object Model, the tree of elements the browser keeps for a page. Every element costs memory and layout time.
  • Canvas: one HTML element that holds a bitmap. Your code paints text and lines on it; the browser does not know a cell is there.
  • Formula cell: a cell whose value is computed, such as the total cell that adds up the stock column.
  • Precedent and dependent: if the total reads B5, then B5 is a precedent of the total, and the total is a dependent of B5.
  • Dependency graph: all those reads stored as arrows between cells, so you can ask who reads this cell.
  • Dirty cell: a formula cell whose inputs changed, so its stored value is stale until it is recalculated.
  • MIME type: a label such as text/plain or text/html that says what format a piece of clipboard data is in.

How does spreadsheet frontend design fit together?

Spreadsheet frontend design splits the app into four parts: a sparse cell store, a layout index, a renderer and an input layer, with a calculation engine beside them. Each part has one job, so each can be made fast on its own.

The cell store is sparse, meaning it only keeps cells that hold something. A sheet the size of Excel's, 1,048,576 rows by 16,384 columns as of 27 October 2026, has about 17.2 billion addresses. The stock sheet fills a few lakh of them, so a map keyed by row and column is enough.

The layout index knows every row height and column width, and turns a scroll position into the first visible row. The renderer draws only the visible cells. The input layer handles the selection, the cell editor and the clipboard. The calculation engine keeps the dependency graph and recomputes formulas.

How a scroll or an edit becomes pixels

Notice that the renderer never walks the whole sheet. It asks the store only for the cells in the visible range, and edits reach the screen through the engine.

Diagram connections:

  • Scroll or resize to Layout index (scroll position, width, height)
  • Layout index to Sparse cell store (read visible range)
  • Sparse cell store to Canvas renderer (values and formats)
  • Editor overlay to Calculation engine (new cell value)
  • Calculation engine to Sparse cell store (recalculated values)
  • Scroll or resize: Gives scroll position (scrollTop) and size
  • Layout index: Turns pixels into first and last row and column
  • Sparse cell store: Only non-empty cells, keyed by row and column
  • Canvas renderer: Paints visible cells once per animation frame
  • Editor overlay: A real text box placed over the active cell
  • Calculation engine: Dependency graph, dirty set, topological order

How does virtualised rendering show all 1,048,576 rows?

It draws only the rows and columns inside the viewport, plus a few extra, and fakes the rest with a correct scrollbar. Virtualisation (also spelt virtualization) keeps the work per frame tied to the screen size, not the sheet size.

With 20 pixel rows and 100 pixel columns, a 1280 by 720 viewport shows at most 37 rows and 14 columns. That is 518 cells, whether the sheet has 100 rows or 1,048,576.

For fixed-height rows the maths is one division. The function below returns the first and last row to draw. It adds a few overscan rows, extra rows drawn just outside the screen so fast scrolling shows no gaps.

javascript
function visibleRange(scrollTop, viewportHeight, rowHeight, rowCount, overscan) {
  const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const lastOnScreen = Math.floor((scrollTop + viewportHeight - 1) / rowHeight);
  const last = Math.min(rowCount - 1, lastOnScreen + overscan);
  return { first: first, last: last, offsetY: first * rowHeight - scrollTop };
}

Pause and predict: the user has scrolled down 1,000,010 pixels, which the browser reports as scrollTop, with overscan 3. Which row is at the top of the screen, and where do you start drawing?

Row 50,000 is at the top, because 1,000,010 divided by 20 is 50,000.5. With overscan the function returns rows 49,997 to 50,039 and offsetY of minus 70. offsetY is where the first drawn row starts, relative to the top of the viewport. You draw row 49,997 at 70 pixels above the viewport, so row 50,000 lands 10 pixels above the top edge, half hidden.

What just happened? One division replaced a loop over 50,000 rows. We ran this function in Node 22: rows 0 to 35 at the top, 49,997 to 50,039 in the middle and 1,048,537 to 1,048,575 at the very end.

The same idea, for a plain list of items, is the heart of our virtualised list machine coding guide. A spreadsheet applies it twice, once for rows and once for columns.

Variable row heights need a prefix-sum index

Once a user wraps text or resizes a row, the division stops working. The top of row i is the sum of all heights above it, called a prefix sum. Adding them one by one is too slow: reaching 15,000,000 pixels meant visiting 742,720 rows in our test.

A Fenwick tree, also called a binary indexed tree, stores partial sums so both jobs take about 20 steps for a million rows. Changing one row height updates about 20 entries. Finding the row at a pixel walks down the tree, again in about 20 steps.

We checked our version against brute-force sums on 1,048,576 rows, after giving 5,000 random rows new heights. All 20,000 random lookups matched, and 100,000 resize-plus-lookup pairs took 62 milliseconds in Node 22. Columns use a second, smaller tree.

Browsers cap how tall a scroll area can be

A common way to make the scrollbar honest is one tall, empty spacer element. The catch is that browsers clamp element sizes. In Chromium 153, every height we tried above 33,554,428 pixels, up to 100 million, was laid out as exactly 33,554,428.

This cap is undocumented as of 27 October 2026, and other engines may use different limits, so measure on the browsers you support. At 20 pixel rows, Excel's full row count is 20,971,520 pixels, which fits. At 40 pixel rows it is 41,943,040 pixels, which does not.

One fix is scaling: map the spacer's scroll range onto the real height with one factor. For 40 pixel rows the factor is 1.25, so each pixel of scrollbar movement moves 1.25 pixels of content. Apply the factor only to the scrollbar, never to keyboard moves, so arrow keys still land on exact rows.

Should a spreadsheet grid use canvas or DOM rendering?

Our recommendation: paint the cell area of a big, live sheet on canvas, and let the DOM handle editing and accessibility. A virtualised DOM grid is simpler and is often enough for read-mostly tables.

With DOM rendering every visible cell is an element. Scrolling means reusing those elements with new text and positions, and the browser gives you text layout, text selection and accessibility hooks. Each element still costs style and layout work, and borders, frozen rows and merged cells become many extra elements.

With canvas rendering you paint lines and text yourself, once per animation frame. requestAnimationFrame is the browser call that runs your drawing code just before the next repaint, usually matching the screen's refresh rate. At 60 Hz that gives you about 16.7 milliseconds per frame.

We timed one scroll frame for 518 cells in headless Chromium on a 2-CPU test machine, averaged over 100 frames per run. Reused DOM elements took 17.5 to 65.8 milliseconds per frame across runs. Repainting a canvas took 0.7 to 7.5 milliseconds.

How the two rendering choices compare for the cell area. Timings are ranges across runs in headless Chromium 153 on one 2-CPU machine, with made-up cell text.
QuestionVirtualised DOM gridCanvas grid
What you build per visible cellOne element, reused while scrollingNothing; you paint pixels
One scroll frame of 518 cells, our runs17.5 to 65.8 ms (update text, position, layout)0.7 to 7.5 ms (clear and repaint)
Text input and IME (input method editor)Native in each cell if you wantNeeds a DOM text box over the active cell
Screen readersReal grid roles on real elementsInvisible; needs a parallel grid of elements with ARIA accessibility attributes
Sharp text on high-density screensAutomaticYou scale the bitmap for the screen's pixel density yourself
Borders, frozen panes, merged cellsMany extra elements and CSSA few more drawing calls
Best fitRead-mostly tables and admin screensLarge live sheets with formulas

An IME is the tool people use to type Hindi, Tamil or Japanese. Canvas has no text box of its own, so a canvas grid needs a real text element placed over the active cell while it is being edited. The browser then handles typing, IME composition and spell-check.

Getting a canvas sharp and within limits

A canvas has two sizes: its size on the page (its CSS size) and its bitmap size in memory. devicePixelRatio tells you how many physical pixels sit in one CSS pixel. Set the bitmap to CSS size times that ratio, then scale the drawing context by the same ratio, as MDN shows.

Only size the canvas to the viewport, never to the sheet. A 1920 by 1080 viewport at ratio 2 is a 3840 by 2160 bitmap, about 31.6 MiB (mebibytes, roughly 33 MB) at 4 bytes per pixel. A sheet-sized canvas would be millions of pixels tall.

As of 27 October 2026, MDN says most browsers allow canvases above 10,000 by 10,000 pixels, but iOS devices limit them to 4,096 by 4,096. Past the limit, drawing simply stops working. In Chromium 153 we could draw on a 65,535 pixel wide canvas but not 65,536. A 16,384 by 16,384 canvas worked and one pixel wider failed, which points to an area limit.

A canvas is invisible to screen readers, as MDN warns. Keep a visually hidden grid of real elements for the visible cells, with the grid role. Add aria-rowcount, which tells assistive technology the full row count when only some rows exist in the DOM.

Beginners can stop here. You now know why a sheet draws only what is visible and how it picks canvas or DOM. The next sections cover formulas, selection and the clipboard, with code and test results for engineers.

How does a formula dependency graph decide what to recalculate?

It follows arrows from the changed cell to every cell that reads it, directly or indirectly, and recalculates only those. Microsoft's Excel developer docs describe the same model: a dependency tree, a calculation chain, and dirty cells re-evaluated in order.

The engine stores the graph twice. Precedents map each formula cell to the cells it reads, which you get by parsing the formula. Dependents map each cell to the formulas that read it, which is the reverse. You need dependents to find the dirty set and precedents to know when a cell is ready.

Think of a kitchen making a thali. A dish is a cell, its ingredients are its precedents, and the dishes that use it are its dependents. A dish waits until every ingredient it needs is ready, then it is cooked once. A recipe that needs itself as an ingredient can never start: that is a cycle.

Where the picture breaks: a kitchen cooks every dish each time. A sheet redoes only the dirty part, and a cell is recalculated in microseconds, not minutes.

Pause and predict. Cell A1 holds 1. B1 is A1 plus 1, C1 is A1 times 10, and D1 is B1 plus C1, so D1 is 12. Now type 2 into A1. How many times should D1 be computed?

Once, giving 23. Two common first tries get this wrong, and we ran both in Node 22.

  • Naive try one, wrong: recalculate every formula in row-major order, left to right, top to bottom. In our test a cell in row 1 read a cell in row 2 before row 2 was updated, and showed 12 while its source showed 202.
  • Naive try two, wrong: when a cell changes, immediately recompute each dependent, recursively. D1 was computed twice and briefly showed 13, a value that never exists in a consistent sheet.

That brief 13 is called a glitch: a value mixing the new B1 with the old C1. A chart or a conditional colour could flash with it. Topological order removes glitches, because D1 waits until both B1 and C1 are fresh.

The diamond: who waits for whom after A1 changes

Notice that D1 has two arrows coming in, so its waiting count starts at 2. It runs only after B1 and C1 both finish, which is why it is computed exactly once.

Diagram connections:

  • A1 = 2 (edited) to B1 = A1 + 1 (read by)
  • A1 = 2 (edited) to C1 = A1 * 10 (read by)
  • B1 = A1 + 1 to D1 = B1 + C1 (read by)
  • C1 = A1 * 10 to D1 = B1 + C1 (read by)
  • A1 = 2 (edited): Waits for 0 dirty inputs; runs 1st
  • B1 = A1 + 1: Waits for 1 input; becomes 3
  • C1 = A1 * 10: Waits for 1 input; becomes 20
  • D1 = B1 + C1: Waits for 2 inputs; becomes 23, computed once

How does topological recalculation work step by step?

Collect the dirty set, count how many dirty inputs each dirty cell is waiting for, then repeatedly evaluate cells whose count has reached zero. This is a version of Kahn's algorithm for topological sorting, run only on the dirty part of the graph.

  1. Collect the dirty set

    Start at the edited cell and follow dependents outwards with a stack. Every cell reached is dirty.

  2. Count waiting inputs

    For each dirty cell, count its precedents that are also dirty. Clean precedents already hold correct values, so they do not count.

  3. Seed the ready queue

    Put every dirty cell with a count of zero in a queue. The edited cell is always one of them unless it sits on a cycle.

  4. Evaluate and release

    Take a cell from the queue, evaluate it, and lower the count of each dirty dependent by one. A dependent that reaches zero joins the queue.

  5. Report what is stuck

    When the queue is empty, any dirty cell with a count above zero is on a cycle or downstream of one.

Here is the whole thing in JavaScript. The evaluate function is whatever computes one formula from the store. precedents and dependents are maps from a cell name to a set of cell names.

javascript
function recalc(changed, precedents, dependents, evaluate) {
  // 1. Dirty set: the changed cell and everything downstream of it.
  const dirty = new Set([changed]);
  const stack = [changed];
  while (stack.length) {
    const cell = stack.pop();
    for (const d of dependents.get(cell) || []) {
      if (!dirty.has(d)) { dirty.add(d); stack.push(d); }
    }
  }
  // 2. For each dirty cell, count inputs that are also dirty.
  const waiting = new Map();
  for (const cell of dirty) {
    let n = 0;
    for (const p of precedents.get(cell) || []) if (dirty.has(p)) n++;
    waiting.set(cell, n);
  }
  // 3. Evaluate a cell once all its dirty inputs are done.
  const ready = [...dirty].filter(function (c) { return waiting.get(c) === 0; });
  let head = 0;
  while (head !== ready.length) {
    const cell = ready[head++];
    evaluate(cell);
    for (const d of dependents.get(cell) || []) {
      if (!dirty.has(d)) continue;
      waiting.set(d, waiting.get(d) - 1);
      if (waiting.get(d) === 0) ready.push(d);
    }
  }
  // 4. Cells still waiting sit on a cycle or downstream of one.
  const stuck = [...dirty].filter(function (c) { return waiting.get(c) !== 0; });
  return { order: ready, stuck: stuck };
}

What just happened? On the diamond, the order came out A1, B1, C1, D1, with three formula evaluations and nothing stuck. In a sheet with 100,001 formulas, editing one input caused exactly 2 evaluations in our engine: its own row formula and the grand total.

Excel adds two refinements worth copying. Volatile functions such as NOW, TODAY, RANDBETWEEN, OFFSET and INDIRECT are treated as dirty on every recalculation. When a new formula is entered, Excel rebuilds its dependency tree and calculation chain. Our engine only swaps the edited cell's own arrows, which is cheaper.

How do you detect circular references in a spreadsheet?

You get detection for free from the steps above. Cells on a cycle never reach a waiting count of zero, so they are still waiting when the queue empties. Mark them with an error such as #CYCLE! instead of a value.

We tested A1 equals B1 plus 1, B1 equals C1 plus 1, C1 equals A1 plus 1, with D1 equals A1 times 2 and an unrelated E1. A1, B1, C1 and D1 all came out stuck, and E1 kept its value of 7.

D1 is not on the cycle, but it reads a cell that is, so it cannot have a real value either. If your app must tell them apart, a cell is on the cycle only when following its dependents leads back to itself. A self-reference such as A1 equals A1 plus 1 is the smallest cycle and is caught the same way.

Fixing the cycle heals the sheet. When we set C1 back to a plain 1, the next recalculation gave A1 as 3, B1 as 2 and D1 as 6, with nothing stuck.

Excel's docs say it detects circular references and warns the user, usually as an error to fix. It also offers optional iterative calculation, which repeats the calculation up to a set count, with 32,767 iterations as the documented limit as of 27 October 2026. A web sheet can ship the error first and add iteration later.

Why does recalculating a long formula chain crash with recursion?

Because each step of a recursive recalculation uses a stack frame, and JavaScript engines limit how deep the stack can go. Running totals down a column, where each row reads the row above, make exactly this kind of long chain.

In Node 22 with the default stack, our recursive push version survived a 1,000-cell chain but threw RangeError, Maximum call stack size exceeded, at 5,000 cells. The queue-based version above handled a 200,000-cell chain and produced the right last value.

The exact depth depends on the engine and on how much each frame holds, so never rely on it. Use an explicit stack or queue, as the sample does, for both the dirty walk and the evaluation.

Long chains also take time. If recalculation runs for more than a frame, move the engine into a Web Worker, a background thread that cannot touch the page. Messages to and from a worker are copied, so send changed cell values, not the whole sheet.

How should a spreadsheet selection model work?

Store the selection as ranges, each described by an anchor cell and an active cell, never as a list of selected cells. Pressing select-all on an Excel-sized sheet would otherwise create 17 billion entries.

The anchor is where the selection started. The active cell is where keyboard focus is now, and it is where typing goes. Shift with an arrow key moves only the active cell, so the rectangle grows or shrinks from the fixed anchor.

javascript
function selectionRect(anchor, active) {
  return {
    top: Math.min(anchor.row, active.row),
    bottom: Math.max(anchor.row, active.row),
    left: Math.min(anchor.col, active.col),
    right: Math.max(anchor.col, active.col),
  };
}

With the anchor at row 5, column 3 and the active cell moved to row 2, column 6, the function returned top 2, bottom 5, left 3, right 6. The renderer shades that rectangle; the store never touches the cells inside it.

  • Multiple ranges: let Ctrl or Cmd plus drag add a new range to a list. The last range owns the active cell.
  • Whole rows and columns: store them as ranges with open ends, so they stay correct when rows are added.
  • Keyboard: the W3C grid pattern lists the expected keys, including arrows, Home and End, Page Up and Down, Shift plus arrow to extend, and Ctrl plus A to select all.
  • Focus: keep real keyboard focus on one element, such as the canvas or the hidden grid, and track the active cell in your own state.

How does spreadsheet copy and paste work with the Clipboard API?

Listen for the copy and paste events on the focused grid, and write two formats on copy: text/plain holding TSV and text/html holding a table. The events need no permission because the user pressed the keys. The async navigator.clipboard methods are for buttons and menus.

The two routes behave differently. In a copy event handler you call setData on the event's clipboardData, then cancel the event with preventDefault. The W3C spec says cancelling is what makes the browser write your data instead of the page selection.

javascript
grid.addEventListener("copy", function (event) {
  const rows = selectedValues();
  event.clipboardData.setData("text/plain", toTSV(rows));
  event.clipboardData.setData("text/html", toHTMLTable(rows));
  event.preventDefault();
});

selectedValues returns the selected cells as rows of strings, and toHTMLTable builds the table markup. When building it, escape the ampersand, the less-than sign and quotes in every cell, so a cell's text can never become markup.

A canvas has no text selection, so does copy even fire? In Chromium 153 it did. With focus on a canvas given a tabindex of 0, pressing Ctrl plus C fired copy with the canvas as the target, and both formats reached the clipboard.

The async API is stricter, and browsers differ, as MDN documents as of 27 October 2026. It works only in secure contexts such as HTTPS pages. Chromium asks for a clipboard-read permission to read. Firefox and Safari show a one-item Paste menu instead and do not support those permissions.

Clipboard behaviour we ran in Chromium 153 through Playwright 1.63, as of 27 October 2026. Other browsers can differ; check MDN's compatibility tables.
What we triedResult
Ctrl plus C with focus on a canvascopy fired on the canvas; text/plain and text/html both written
A copy event built and dispatched by scriptClipboard unchanged, as MDN says
Ctrl plus V with focus on a canvaspaste fired with text/plain and text/html available
Async write of a text/csv itemRejected with NotAllowedError: type not supported on write
ClipboardItem.supports for a web custom typetrue for a type starting with web and a space
Async read of HTML that contained a scriptScript removed and a tbody element added
Same read with the unsanitized option for text/htmlScript kept, so treat that HTML as untrusted
readText with no permission and no user gestureRejected with NotAllowedError

Why send both TSV text and an HTML table?

Because the app you paste into picks the format it understands. A plain text editor reads text/plain. A rich editor, such as an email or document editor, that reads text/html can rebuild the rows and columns as a real table. That fixes the stock sheet's jumbled email.

The W3C spec, in its 24 June 2026 Editor's Draft, names text/plain, text/html and image/png as the formats every browser must support. There is no CSV slot, and Chromium 153 rejected text/csv on write. The spec's own example of a custom format is web text/csv. So TSV, tab-separated values, goes inside text/plain.

Plain TSV has a gap. The IANA registration for tab-separated values says fields containing tabs are not allowed. Real cells hold tabs, line breaks and quotes. Our code borrows the quoting rules of RFC 4180, the CSV memo: wrap such a field in double quotes and double any quote inside it.

javascript
function toTSV(rows) {
  return rows.map(function (row) {
    return row.map(function (value) {
      const s = String(value);
      if (!/[\t\n\r"]/.test(s)) return s;
      return '"' + s.replace(/"/g, '""') + '"';
    }).join("\t");
  }).join("\n");
}

Reading it back needs a small state machine, not split. A cell holding two lines would otherwise become two rows. On our test data a naive split on line breaks and tabs gave 5 rows instead of 4.

javascript
function parseTSV(text) {
  const rows = [[]];
  let cell = "";
  let quoted = false;
  for (let i = 0; i !== text.length; i++) {
    const ch = text[i];
    if (quoted) {
      if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++; }
      else if (ch === '"') quoted = false;
      else cell += ch;
    } else if (ch === '"' && cell === "") quoted = true;
    else if (ch === "\t") { rows[rows.length - 1].push(cell); cell = ""; }
    else if (ch === "\n") { rows[rows.length - 1].push(cell); cell = ""; rows.push([]); }
    else if (ch !== "\r") cell += ch;
  }
  rows[rows.length - 1].push(cell);
  // A trailing line break leaves one empty row; drop it.
  // (A real last row holding a single empty cell is dropped too.)
  const lastRow = rows[rows.length - 1];
  if (rows.length !== 1 && lastRow.length === 1 && lastRow[0] === "") rows.pop();
  return rows;
}

Both functions round-tripped a grid with a quoted word, a two-line cell, a tab inside a cell and an empty cell. Line endings written as carriage return plus line feed also parsed correctly. In Chromium, a pasted table parsed with DOMParser came back as the rows Item, Qty and Chai, 2.

Pasting safely and keeping formulas

On paste, prefer your own custom format, then text/html, then text/plain. A custom format lets your app carry formulas and formatting between its own tabs. Write it with the async API, naming it with the web prefix, such as web text/x-grid+json. Chromium 153 wrote and read one back for us. The paste event does not carry it, though. In Chromium 153 a Ctrl plus V paste event showed only text/plain. Reading your format needs navigator.clipboard.read, which asks for permission in Chromium and shows a Paste prompt in Firefox and Safari, as of 27 October 2026. If that is refused, fall back to text/html and text/plain from the paste event.

Treat pasted HTML as untrusted. Parse it with DOMParser, which builds a separate, inert document, read each table cell's textContent, and never insert the HTML into the page. MDN warns that inserted nodes can run scripts and that parsing can still download images.

One copy, two formats, two kinds of paste target

Notice that the grid writes both formats in one copy event. Each paste target reads the format it understands, so nobody gets a jumbled line.

Diagram connections:

  • Ctrl plus C on the grid to copy event handler (copy event)
  • copy event handler to text/plain as TSV (setData text/plain)
  • copy event handler to text/html table (setData text/html)
  • text/plain as TSV to System clipboard (after preventDefault)
  • text/html table to System clipboard (after preventDefault)
  • System clipboard to Another spreadsheet (paste)
  • System clipboard to Email or docs editor (paste)
  • Ctrl plus C on the grid: User gesture on the focused canvas
  • copy event handler: Reads the selection ranges from state
  • text/plain as TSV: Tabs, line breaks, RFC 4180-style quotes
  • text/html table: Escaped cell text inside table rows
  • System clipboard: Holds both formats from one copy
  • Another spreadsheet: Picks a format it understands
  • Email or docs editor: Can rebuild rows from the HTML table

How do you explain spreadsheet frontend design in a system design interview?

Walk from the screen inwards: rendering first, then data, then formulas, then input. Give one number per decision. Our calendar frontend system design guide is a useful contrast: a week view in half-hour slots has 336 cells, while a sheet has billions of addresses.

  1. State the scale

    Up to 1,048,576 rows by 16,384 columns, mostly empty. One screen shows about 518 cells. Edits must show in the same frame.

  2. Choose the renderer

    Virtualise rows and columns. Paint cells on a viewport-sized canvas scaled by devicePixelRatio, with a DOM editor overlay and a hidden ARIA grid.

  3. Describe the data

    A sparse map of non-empty cells. Fenwick trees for variable row heights and column widths, giving about 20-step lookups.

  4. Explain recalculation

    Precedent and dependent maps. Mark the dirty set, evaluate in topological order with a queue, and flag leftover cells as cycles.

  5. Cover selection and clipboard

    Ranges of anchor plus active cell. Copy writes TSV and an HTML table; paste tries a custom format through the async API, then HTML, then text.

  6. Name the limits

    Browser scroll height caps, canvas size limits, deep chains needing iteration, and a Web Worker once recalculation exceeds one frame.

If the interviewer pushes on collaboration, say that each edit becomes an operation sent to a server, and point to operational transforms or CRDTs, data types that merge concurrent edits. Our guide on collaborative editing covers that trade-off.

Questions people ask

Is canvas or DOM better for a spreadsheet grid?
For the cell area of a large, live sheet, we recommend canvas; for read-mostly tables, a virtualised DOM grid is simpler. In our headless runs, one scroll frame of 518 cells took 0.7 to 7.5 milliseconds on canvas and 17.5 to 65.8 with reused elements. Keep real elements for the cell editor, focus and screen readers, because a canvas is invisible to them.
How does a spreadsheet know which cells to recalculate?
It keeps a dependency graph of which cells read which. When a cell changes, the engine follows the arrows to every direct and indirect dependent and marks them dirty. It then evaluates only the dirty cells, each one after all of its inputs. In our engine, one edit among 100,001 formulas caused just 2 evaluations.
How do you detect a circular reference in a formula graph?
Run topological recalculation with waiting counts. A cell on a cycle waits for itself, so its count never reaches zero and it is left over when the queue empties. Cells downstream of the cycle are left over too. Show an error in those cells and tell the user which cells form the loop.
Why does pasting spreadsheet cells into email lose the table?
Usually because the app put only text/plain on the clipboard. A rich editor that reads text/html can rebuild a real table, but only if one is there. Write both in the copy event: tab-separated text for spreadsheets and text editors, and an HTML table for rich editors. Escape every cell's text when you build the table.
Do I need clipboard permission to copy from a web spreadsheet?
Not for Ctrl plus C and Ctrl plus V. The copy and paste events fire on the user's key press, and you set data on the event without asking. The async navigator.clipboard methods need a secure context, and reading needs a permission in Chromium or a Paste prompt in Firefox and Safari, as of 27 October 2026. Your own web custom format is only readable that way.
How many rows can a virtualised grid scroll through?
As many as its scroll math allows, but a single tall spacer element hits a browser size cap. In Chromium 153 heights above 33,554,428 pixels were clamped in our test. That fits 1,048,576 rows of 20 pixels but not of 40. Past the cap, scale scroll positions by a factor, here 1.25.

Sources and further reading

Practise this with Lala

Practise