Skip to content

6.9 — Canvas, Virtualisation & Heavy Apps

Render 50,000 rows as DOM elements and the tab stops responding for several seconds, then uses about a gigabyte of memory, and every subsequent style change is slow forever.

The DOM is not a bad technology; it is a technology with a cost per node. Each element carries a JavaScript wrapper object, an internal node, style data, a layout box and, when painted, pixels. That cost is small — and Chapter 6.1.2 established that layout scales with the number of boxes, so at some size the total becomes the problem.

This page is about what to do past that point: render fewer nodes, or stop using nodes at all.

1. Where the ceiling actually is

Useful working figures, and they are approximate on purpose because they depend on the elements and the device:

  • Up to a few thousand nodes: fine. Do not optimise.
  • Around 10,000: initial layout becomes noticeable, and a full restyle takes tens of milliseconds.
  • Beyond about 30,000–50,000: every interaction is slow and memory is measured in hundreds of megabytes.

On a mid-range phone, divide all of those by three or four.

Two things matter more than the raw count. Depth: a deeply nested tree costs more per node than a flat one, because style and layout work propagates. And churn: adding and removing thousands of nodes repeatedly is far worse than having them sit there, because each change invalidates layout.

So the decision ladder is:

Render fewer nodes — virtualisation. Keeps the DOM, keeps accessibility, keeps CSS. Right for lists, tables and grids.

Stop using nodes — canvas. You draw pixels and manage everything yourself. Right for a diagram editor, a map, a chart with 100,000 points, a design tool.

Both — a canvas for the drawing surface, DOM for the interface around it and for text editing. This is what real applications do.

2. Virtualisation

Only render what is visible, plus a margin. A list of 50,000 rows keeps perhaps 30 in the DOM.

tsx
function VirtualList({ items, rowHeight = 48, overscan = 5 }: Props) {
  const viewportRef = useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = useState(0);
  const [height, setHeight] = useState(0);

  // (1) Measure the viewport without forcing layout on every frame.
  useEffect(() => {
    const ro = new ResizeObserver(([e]) => setHeight(e.contentRect.height));
    ro.observe(viewportRef.current!);
    return () => ro.disconnect();
  }, []);

  const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);       // (2)
  const count = Math.ceil(height / rowHeight) + overscan * 2;
  const visible = items.slice(first, first + count);

  return (
    <div
      ref={viewportRef}
      style={{ height: '100%', overflowY: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}                  // (3)
    >
      {/* (4) A spacer of the full height, so the scrollbar is honest. */}
      <div style={{ height: items.length * rowHeight, position: 'relative' }}>
        {visible.map((item, i) => (
          <div
            key={item.id}
            style={{
              position: 'absolute',
              top: (first + i) * rowHeight,     // (5) placed, not stacked
              height: rowHeight,
              left: 0, right: 0,
            }}
          >
            <Row item={item} />
          </div>
        ))}
      </div>
    </div>
  );
}

Line (2) is the arithmetic: from the scroll position, work out the first visible index; from the viewport height, how many fit. overscan renders a few extra above and below so a fast scroll does not show blank space before React catches up.

Line (4) is what makes the scrollbar correct. The spacer is the full 50,000 × 48 pixels tall, so the browser's native scrolling, the scrollbar thumb size, and Ctrl+End all behave normally.

Line (5) positions each row absolutely at its true offset rather than letting them stack, which is what allows only a window of rows to exist.

Line (3) uses a scroll handler, and this is the one place where that is acceptable — it reads scrollTop, which does not force layout, and sets state. Reading getBoundingClientRect() here would reintroduce the problem from Chapter 6.1.2.

Variable heights, which is the hard part

Fixed rows are arithmetic. Variable rows — a chat with messages of different lengths — are not, because you cannot compute the offset of row 4,000 without knowing the heights of the 3,999 above it.

The standard solution is three steps:

Estimate. Assume a height for unmeasured rows and build a running-offset table from the estimates.

Measure on render. When a row is rendered, a ResizeObserver (Chapter 6.3.2) reports its real height, which is stored.

Correct without visible jumping. When a measured height differs from the estimate, every offset below it shifts. If the changed row is above the viewport, the content the user is looking at will move — so you must adjust scrollTop by the same delta in the same frame. Getting this wrong produces the scroll juddering that plagues badly implemented chat windows.

This is the reason to use a maintained virtualisation library rather than writing your own. The window arithmetic is twenty lines; the measurement-and-correction loop is where the difficulty lives.

What virtualisation breaks, and what to do about it

Browser find (Ctrl+F) only searches rendered rows. There is no fix within the technique. Provide your own search that queries the full dataset and scrolls to the result.

Anchor links and "scroll to item" need index-based scrolling — compute the offset and set scrollTop — because the element may not exist yet.

Screen readers need the list's real size. Rendering 30 of 50,000 rows announces "list, 30 items". Set aria-setsize to the true total and aria-posinset to each row's real index, so the user is told "item 4,012 of 50,000".

Keyboard navigation must move by index, not by focused element, since the next item may not be rendered. Move the index, render, then focus.

Sticky headers inside a virtual list need care: a position: sticky header inside an absolutely positioned row does not stick, because the row is its own containing block (Chapter 6.2.5). Render group headers as separate positioned elements outside the row flow.

3. Why Excel's grid is finite

Modern Excel stops at 1,048,576 rows and 16,384 columns — exactly 2^{20} and 2^{14}. The last column is XFD.

An "infinite" grid is easy to imagine and the bound is deliberate. Four reasons, and they generalise to any large-grid interface you build:

A row index has to fit in a fixed-size number for indexing to be fast and for the file format to have fixed-width fields. Twenty bits is a comfortable, generous choice that also leaves room in a packed cell address.

Cell references are text. XFD1048576 is already eleven characters; unbounded references would make formula parsing, the name box and the file format all variable in ways that cost performance everywhere.

The scrollbar needs a total. A scrollbar thumb's size and position are ratios against a known total extent. With no total there is nothing to compute, which is why genuinely infinite surfaces (the next section) use panning rather than scrollbars.

A bound simplifies everything downstream. Memory calculations, undo history, the calculation dependency graph and the file format all get simpler with a maximum. The limit is not a compromise forced by hardware; it is a design decision that buys simplicity for a case nobody legitimately hits.

And the storage is sparse. Sixteen billion cells clearly are not allocated. The grid is a map from (row, column) to a cell, holding only what exists — a hash map keyed on a packed address (Chapter 4.3). The addressable space is finite and large; the stored data is proportional to what the user typed. That combination — a bounded coordinate space over sparse storage — is the right model for any large-grid interface.

4. Canvas: you draw, and you keep the model

The 2D canvas context is immediate mode. You issue drawing commands, pixels appear, and the canvas retains nothing about what you drew. There is no rect.x to update afterwards, no click target, no accessibility node.

So you must keep the model yourself, and that model is called a scene graph: a tree of nodes, each with geometry, style, a transform, and children.

ts
type Node = {
  id: string;
  kind: 'rect' | 'ellipse' | 'text' | 'group';
  x: number; y: number; width: number; height: number;   // in WORLD coordinates
  rotation: number;
  fill?: string;
  children?: Node[];
};

Every frame, you clear and redraw. That sounds wasteful and is not: drawing a few hundred shapes into a canvas is far cheaper than making the browser lay out a few hundred elements, because there is no cascade, no layout, no text flow — just fills.

Two coordinate systems, one matrix

An infinite canvas has world coordinates (where a shape lives in the document, unbounded) and screen coordinates (where it appears in the viewport). Pan and zoom are the transformation between them, and the whole camera is three numbers:

ts
type Camera = { x: number; y: number; zoom: number };

// world → screen
const toScreen = (p: Point, c: Camera) => ({
  sx: (p.x - c.x) * c.zoom,
  sy: (p.y - c.y) * c.zoom,
});

// screen → world — needed for EVERY pointer event
const toWorld = (sx: number, sy: number, c: Camera) => ({
  x: sx / c.zoom + c.x,
  y: sy / c.zoom + c.y,
});
world coordinates — unboundedcamera { x, y, zoom }screen — the canvas elementdrawa click arrives here —toWorld() converts it backpurple shapes are culled: outside the camera, never drawn
The camera is the only thing that changes when you pan or zoom. Shapes keep their world coordinates forever.

toWorld is the function you will call most. A click arrives in screen pixels and every question you want to ask — what did I hit, where do I place this, what is selected — is in world coordinates.

Applying the camera once per frame means the shape drawing code never thinks about zoom:

ts
function render(ctx: CanvasRenderingContext2D, scene: Node[], cam: Camera, dpr: number) {
  ctx.setTransform(1, 0, 0, 1, 0, 0);            // (1) reset
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  ctx.scale(dpr, dpr);                            // (2) device pixels
  ctx.scale(cam.zoom, cam.zoom);                  // (3) zoom
  ctx.translate(-cam.x, -cam.y);                  // (4) pan

  for (const node of visibleNodes(scene, cam)) {  // (5) culling
    drawNode(ctx, node);                          // plain world coordinates
  }
}

Line (2) is the crispness fix. On a 2× display, a canvas whose CSS size is 800×600 must have a backing store of 1600×1200 or everything is blurry:

ts
const dpr = window.devicePixelRatio || 1;
canvas.width  = Math.round(cssWidth  * dpr);   // the pixel buffer
canvas.height = Math.round(cssHeight * dpr);
canvas.style.width  = `${cssWidth}px`;          // the layout size
canvas.style.height = `${cssHeight}px`;

ResizeObserver's devicePixelContentBoxSize (Chapter 6.3.2) gives the exact device-pixel size and avoids rounding drift on fractional ratios.

Zooming toward the cursor, which is what makes a canvas feel right, is one formula: keep the world point under the pointer fixed.

ts
function zoomAt(cam: Camera, screenX: number, screenY: number, factor: number): Camera {
  const before = toWorld(screenX, screenY, cam);                     // (1)
  const zoom = clamp(cam.zoom * factor, 0.02, 64);                   // (2)
  const after = toWorld(screenX, screenY, { ...cam, zoom });         // (3)
  return { zoom, x: cam.x + (before.x - after.x), y: cam.y + (before.y - after.y) };  // (4)
}

Line (1) is the world point under the cursor now. Line (3) is what would be under the cursor after zooming with no pan. Line (4) shifts the camera by the difference, so the point does not move. Line (2)'s clamp matters more than it looks — without a floor, a fast trackpad pinch can reach a zoom of 1e-9, where every coordinate becomes a floating-point mess and the canvas appears to vanish permanently.

Culling with a spatial index

Line (5) above is visibleNodes. With 200 shapes, a linear scan checking each bounding box against the viewport is fine. With 200,000, it is not — you would test every shape every frame just to draw the twelve on screen.

A spatial index answers "what is in this rectangle" in roughly logarithmic time. Three options in increasing order of effort:

Uniform grid buckets. Divide the world into fixed cells and store each shape in the buckets it overlaps. Query by looking at the buckets the viewport covers. Trivial to implement, excellent when shapes are evenly spread, poor when they cluster.

A quadtree. Recursively subdivide each region into four when it holds too many shapes. Adapts to clustering, and is the standard answer. It is a tree with the same reasoning as Chapter 4.13's trees, applied to two dimensions.

An R-tree. Groups nearby bounding boxes into a balanced tree of rectangles. Better for shapes of wildly differing sizes.

The practical advice: start with a linear scan. Add an index when profiling shows the scan is the cost, and reach for grid buckets before a quadtree. Premature spatial indexing is one of the classic ways to make a canvas project slow to build without making it fast.

Layers and dirty regions

Redrawing everything at 60 frames per second while dragging one shape is wasteful. Two techniques, in order of usefulness:

Layered canvases. Two stacked <canvas> elements: a static one holding the whole scene, and a small interaction one on top holding the shape being dragged and the selection handles. During a drag you clear and redraw only the top layer, which holds one shape. Commit to the static layer when the drag ends. This is simple and it is what most editors do.

Dirty rectangles. Track which world regions changed, clip() to their union, and redraw only what intersects. Powerful, and the bookkeeping is genuinely fiddly — antialiasing and shadows bleed a pixel or two outside a shape's bounds, so a rectangle that is exactly right leaves artefacts and you must pad it.

Hit testing

Which shape did the user click?

Iterate the scene in reverse order — topmost first — and return the first whose bounds contain the world point. Reverse, because the last drawn is on top.

Then refine. A bounding-box check is enough for a rectangle, and wrong for an ellipse, a rotated shape, or a line. ctx.isPointInPath() gives an exact answer against the current path, and for a thin line you want a tolerance of a few pixels or it becomes impossible to click.

For very complex scenes, a pick buffer is the trick worth knowing: render the scene a second time, off screen, with each shape filled in a unique flat colour derived from its id. A click reads one pixel from that buffer and the colour is the identity. One getImageData call replaces any amount of geometry, and it handles arbitrary shapes exactly.

Text is where canvas hurts

ctx.fillText() draws text. That is all it does. There is no wrapping, no selection, no cursor, no copy, no find, no screen-reader access, and no input method support.

So text in a canvas application is nearly always a DOM overlay. The canvas draws the shape and the static rendering; when the user starts editing, you position a real <textarea> or a contenteditable element over that area, transformed to match the camera, and let the browser handle everything text-related. On commit, the text goes back into the model and the DOM element disappears.

Accessibility, honestly

A canvas is a single element with no internal structure, so a scene of 500 shapes is one node to a screen reader.

The mitigation is a parallel DOM: keep a hidden but focusable tree of elements mirroring the scene's semantics, with the canvas marked aria-hidden="true" and the mirror carrying the roles, names and states. Keyboard navigation moves through the mirror and the canvas renders the selection.

That is real work, and it is worth being straight about it: canvas applications are hard to make accessible, and most are not. If the content can be expressed as DOM at an acceptable cost, that is the better default. Choose canvas because the DOM genuinely cannot carry the load, not because it seems faster.

WebGL, WebGPU and workers

The 2D context is drawn by the GPU underneath, but each fill is a separate operation. Past roughly ten thousand shapes per frame, the per-call overhead dominates.

WebGL and WebGPU let you batch thousands of shapes into a single draw call with vertex data uploaded once. That is why serious design tools ship a custom renderer. The cost is a large jump in complexity — shaders, buffers, your own text rendering, your own antialiasing — and it is only worth paying when profiling proves the 2D context is the bottleneck.

OffscreenCanvas moves rendering to a worker (Chapter 6.3.3):

ts
const offscreen = canvas.transferControlToOffscreen();      // (1)
worker.postMessage({ canvas: offscreen }, [offscreen]);     // (2) transferred

Line (2) uses the transfer list, so ownership moves rather than copying. The worker then renders while the main thread stays free for input — which is what keeps an application responsive during a heavy redraw.

5. Rich text editors

Text editing looks like it should be easy and is one of the hardest things in frontend. The reason is contenteditable.

Why raw contenteditable fails

Put contenteditable="true" on a <div> and you have an editor. Then:

  • Each browser produces different markup for the same action. Enter creates a <div>, a <p> or a <br> depending on the browser and the context.
  • Paste inserts arbitrary HTML, complete with a word processor's inline styles and <span> soup, and it is an injection risk (Chapter 6.10).
  • The undo stack is the browser's, and it does not know about anything your code did, so a programmatic change breaks undo.
  • The DOM can end up in states your model cannot represent — a <strong> split across three nodes, an empty formatting element, a nested list the user cannot escape.
  • Input methods for Chinese, Japanese and Korean compose text over several keystrokes; interfering mid-composition corrupts the input.

The architecture that works

Every serious editor is built the same way, and it is one idea:

A document model is the source of truth. contenteditable is only an input surface. Intercept the intent, apply it to the model, and re-render.

ts
// (1) A model that can represent exactly the documents you allow.
type Doc = { type: 'doc'; content: Block[] };
type Block =
  | { type: 'paragraph'; content: Inline[] }
  | { type: 'heading'; level: 1 | 2 | 3; content: Inline[] }
  | { type: 'list'; ordered: boolean; items: Block[][] };
type Inline = { text: string; marks?: ('bold' | 'italic' | 'code' | 'link')[] };

Line (1) is where the design happens. The model defines what a document can be, so the invalid states contenteditable can produce simply have no representation — this is Chapter 3.7.3's discriminated unions and Chapter 9.4.14's modelling argument applied to a document.

ts
editor.addEventListener('beforeinput', (e: InputEvent) => {
  e.preventDefault();                                   // (2) the browser does nothing

  const selection = mapDomSelectionToModel(getSelection()!);   // (3)

  switch (e.inputType) {                                // (4)
    case 'insertText':
      applyTransaction(insertText(selection, e.data ?? ''));
      break;
    case 'deleteContentBackward':
      applyTransaction(deleteBackward(selection));
      break;
    case 'insertParagraph':
      applyTransaction(splitBlock(selection));
      break;
    case 'formatBold':
      applyTransaction(toggleMark(selection, 'bold'));
      break;
  }
});

Line (2) is the whole trick. beforeinput fires before the browser modifies anything, and preventDefault() stops it. The browser then never touches the DOM; your code decides what the change means, applies it to the model, and re-renders. This is what makes behaviour identical across browsers.

Line (4) inputType is the genuinely useful part of the event: it tells you the intentinsertText, deleteContentBackward, insertParagraph, formatBold, historyUndo — regardless of whether it came from a key, a toolbar, a context menu, voice dictation or an autocorrect. You handle intents, not keystrokes.

Line (3) is the constant tax of this architecture: mapping between DOM selection and model positions, in both directions. The DOM selection is a node plus an offset; the model position is a path plus an index. Every operation converts one way, and every render converts back to restore the caret. Getting this wrong is why a caret jumps to the start of the document after typing.

Undo is at the model level. Each transaction records enough to invert it, and your history stack holds transactions. This is Chapter 9.4.15's Command pattern, and Chapter 9.7.12 designs the undo machinery properly.

Composition needs an exception. During an input-method composition (compositionstart to compositionend), let the browser manage the DOM and reconcile with the model at the end. Preventing default mid-composition breaks typing in several languages entirely.

Which is why editor frameworks exist. ProseMirror, Lexical and Slate all implement this architecture — a schema-validated model, transactions, selection mapping, and composition handling. Building a rich text editor from scratch is a multi-year project, and the correct default is to take one of these and define your own schema on top.

The collaboration bridge

Once the document is a model with transactions rather than a DOM, real-time collaboration becomes tractable, because you are merging operations rather than merging HTML.

Two families solve the merge. Operational transformation sends operations and transforms each incoming one against the operations applied since it was created, so intent is preserved. Conflict-free replicated data types give every character a unique identifier and an ordering rule so that concurrent edits converge with no central coordinator.

Chapter 11.13 designs a collaborative editor end to end and prices both properly. The point for this page is the dependency: you cannot bolt collaboration onto a contenteditable editor that has no model. The model is the prerequisite, which is one more reason the architecture above is worth adopting from the start.

What the interviewer will push on

"How would you render a list of 50,000 rows?" Virtualise: a full-height spacer for an honest scrollbar, absolutely positioned rows at computed offsets, and an overscan margin. Then name what it breaks — Ctrl+F, anchors, aria-setsize for the true count, index-based keyboard navigation — because that is what separates having used a library from having understood the technique.

"What makes variable-height virtualisation hard?" You cannot compute an offset without the heights above it. Estimate, measure with a ResizeObserver, and correct scrollTop in the same frame when a row above the viewport changes size, or the content the user is reading jumps.

"Why is Excel's grid finite?" 2^{20} rows and 2^{14} columns: fixed-width indices, text cell references, a scrollbar that needs a total, and a bound that simplifies memory, undo, the dependency graph and the file format. Then add that storage is sparse — a bounded coordinate space over a map of what actually exists.

"How does an infinite canvas work?" A scene graph in world coordinates, a camera of {x, y, zoom}, toWorld for every pointer event, culling by a spatial index, and redraw each frame. Volunteer zoom-toward-cursor as the formula that keeps the point under the pointer fixed, and the clamp that prevents a zoom of 1e-9.

"When would you move from DOM to canvas?" When the node count is the bottleneck and the content is genuinely graphical. Then state the price honestly: no accessibility, no text, no find, no selection — all of which you rebuild. Choosing canvas because it "feels faster" is the wrong reason.

"Why not just use contenteditable?" Divergent markup per browser, paste bringing arbitrary HTML, a browser undo stack that does not know about your changes, and DOM states your model cannot represent. The fix is a model as the source of truth with beforeinput intercepted and prevented.

"What does beforeinput give you?" The intentinsertText, insertParagraph, formatBold, historyUndo — before the DOM changes, from any source including voice and autocorrect. Preventing it means the browser never mutates anything and your model decides.

One thing to volunteer: mention that input-method composition is the exception where you must not prevent default, and that getting it wrong makes the editor unusable for typing Chinese, Japanese or Korean. It is the detail that shows you have thought about users beyond your own keyboard, and it is the bug that ships most often in hand-built editors.

Recall

  • The DOM has a cost per node: comfortable to a few thousand, noticeable near 10,000, unusable past 30,000–50,000, and divide by three or four on a mid-range phone. Depth and churn cost more than raw count.
  • Virtualisation = a full-height spacer (so the scrollbar is honest) + absolutely positioned rows at computed offsets + overscan. It breaks Ctrl+F, anchors and screen-reader counts — fix the last with aria-setsize/aria-posinset and navigate by index.
  • Variable heights need estimate → measure → correct scrollTop in the same frame when a row above the viewport resizes. That correction loop is why you use a maintained library.
  • Excel stops at 2^{20} rows × 2^{14} columns for fixed-width indices, text references, a scrollbar that needs a total, and general simplicity — over sparse storage, so only typed cells exist.
  • Canvas is immediate mode: you keep the scene graph. World versus screen coordinates, a camera of {x, y, zoom}, and toWorld on every pointer event. Zoom toward the cursor by keeping the world point under it fixed, and clamp the zoom or floating point destroys the scene.
  • Multiply the backing store by devicePixelRatio or the canvas is blurry. Cull with a spatial index (grid buckets → quadtree → R-tree) but start with a linear scan.
  • Use layered canvases — static scene below, dragged shape above — before attempting dirty rectangles, whose padding for antialiasing is fiddly.
  • Hit test topmost first, refine with isPointInPath and a tolerance for lines, or use a pick buffer where each shape is drawn in a unique colour and one pixel read gives the identity.
  • Text on canvas gives you nothing — overlay a real DOM element for editing. Canvas accessibility needs a parallel DOM mirror, and most applications do not build one; choose canvas only when the DOM genuinely cannot cope.
  • Rich text: the model is the source of truth, contenteditable is only an input surface. Intercept beforeinput, preventDefault(), read inputType as the intent, apply a transaction, re-render, and map selection both ways. Undo is model-level (the Command pattern). Do not prevent default during IME composition.
  • Collaboration merges operations, not HTML — so the model is the prerequisite for OT or CRDT.

Self-test: What makes the scrollbar correct in a virtualised list? · Why does a measured row above the viewport require a scroll adjustment? · Why is Excel's limit a design decision rather than a hardware one? · What does toWorld exist for? · What exactly does preventDefault() on beforeinput buy you? · Which text input case must not be intercepted?

Next: 6.10 closes Part 6 with the rules the browser enforces on everything above — why one page cannot read another's data, why the same request succeeds from a terminal and fails from JavaScript, and what a content security policy actually changes about a successful injection.