Skip to content
Mog is in active development. The GitHub repo, SDK packages, and community channels are not yet available. Follow for launch updates
← Back to blog
·Mog Team

Why Mog Renders Every Pixel on Canvas

engineeringrenderingcanvasperformance

The DOM Ceiling

Most web spreadsheet libraries render cells as DOM elements -- <div> or <td> nodes arranged in a grid. This is the obvious approach. The browser handles layout, text rendering, and event delegation. It works fine for small grids.

It stops working at scale.

A 1000-row by 100-column visible grid requires 100,000 DOM nodes. Each node participates in layout, paint, and composite phases. CSS recalculation is O(n) on the number of nodes. Add merged cells, conditional formatting overlays, and drawing objects, and you have a layout problem that the browser was never designed to solve efficiently.

Virtual scrolling helps -- render only the visible rows, recycle DOM nodes as the user scrolls. But it introduces its own complexity: layout thrashing during fast scrolls, scroll position synchronization between frozen panes, and edge cases with variable row heights. You end up fighting the browser's layout engine instead of leveraging it.

Canvas sidesteps this entirely. The rendering cost per cell is O(1) -- a few draw calls per visible cell, regardless of total sheet size. You get pixel-level control over every aspect of rendering. And you trade one set of problems (DOM performance) for another (text layout, hit-testing, accessibility), which we think is a better trade.

From Rust to Pixels: The Binary Wire Protocol

Mog's compute core is written in Rust and runs in a Web Worker. The canvas renderer lives on the main thread. These two need to exchange viewport data at 60fps. JSON serialization is out of the question -- it would blow the frame budget on serialization alone.

Instead, the Rust core packs viewport data into a compact binary format:

Header (36 bytes)
viewport bounds (row/col ranges)
cell count (u32)
string pool offset (u32)
format palette offset (u32)
Cell Records (N x 32 bytes each)
row (u32), col (u32)
value type + value (8 bytes)
format index (u16)
string pool index (u16)
flags: merge, overflow, style (8 bytes)
String Pool (deduplicated, length-prefixed)
Format Palette (shared format definitions)

Each cell record is a fixed 32-byte struct. Fixed-size records mean direct indexing -- no parsing, no scanning for delimiters. String values are deduplicated into a shared pool, referenced by index. Format definitions (font, color, borders, alignment) live in a palette, also referenced by index. If 500 cells share the same format, that format is stored once.

The buffer is transferred via postMessage with Transferable ArrayBuffers -- zero-copy across the thread boundary. On the JavaScript side, we read directly from typed arrays:

typescript
const view = new DataView(buffer);
const cellCount = view.getUint32(16, true);
for (let i = 0; i < cellCount; i++) {
const offset = 36 + i * 32;
const row = view.getUint32(offset, true);
const col = view.getUint32(offset + 4, true);
const formatIdx = view.getUint16(offset + 16, true);
// ... draw directly, zero allocations per cell
}

For a typical viewport of 500 visible cells: ~16KB per frame. Well within the 60fps budget even on modest hardware.

Spatial Indexing for Interaction

Canvas gives you pixels, not elements. When a user clicks on a canvas, you get (x, y) coordinates -- there is no event.target. You need to figure out what was clicked.

The naive approach is iterating over all visible cells and checking bounds. That is O(n) per interaction event, which gets expensive with merged regions, drawing objects, charts, and overlay elements all occupying the same coordinate space.

Mog uses an R-tree spatial index that covers every interactive element: cells, merged regions, chart boundaries, drawing objects, resize handles, comment markers. A point query on click or hover is O(log n):

typescript
// Point query returns all elements at (x, y)
const hits = spatialIndex.query(mouseX, mouseY);
// Hits are sorted by z-order -- topmost element first
const target = hits[0];

The same index handles selection rectangles (range query), drag-and-drop (intersection query), and resize handles (nearest-neighbor query). Interaction latency stays constant regardless of sheet size -- a 10-row sheet and a 1,000,000-row sheet have the same click response time.

Multi-Layer Composition

A single canvas for everything would mean repainting 100K cells every time the user drags a selection handle. That is unacceptable.

Mog composites multiple independent canvas layers:

  • Cell grid -- the background layer with cell values, backgrounds, and borders
  • Selection highlights -- the blue selection rectangles and active cell indicator
  • Drag indicators -- visual feedback during drag-and-drop operations
  • Resize handles -- column/row resize affordances
  • Auto-fill previews -- the preview shown when dragging the fill handle
  • Comment markers -- the small triangle indicators for cells with notes
  • Conditional formatting -- data bars, color scales, icon sets rendered as overlays

Each layer is an independent <canvas> element stacked via CSS. When the user drags a selection, only the selection layer redraws. The cell grid -- the most expensive layer -- stays untouched. Column resizing redraws the resize handle layer and the affected column slice, not the entire grid.

The layers are composited with hardware acceleration. The browser's compositor handles the final merge efficiently because each layer is its own GPU texture.

Text Rendering: The Hard Part

Canvas text rendering is the price of admission. The browser's text layout engine -- the one that handles wrapping, sub-pixel positioning, bidirectional text, and font fallback -- is not available to canvas. You build your own or you live with limitations.

Mog's approach:

Text measurement cache. Measuring text with ctx.measureText() is expensive. We cache measurements keyed by (content, font, cellWidth). When a cell's content or format has not changed, we skip measurement entirely.

Cell boundary clipping. Text that overflows a cell boundary is clipped. But if the adjacent cell to the right is empty, long text is allowed to overflow into it -- matching Excel's behavior. This requires checking neighbor occupancy during layout.

Retina rendering. On HiDPI displays, we scale the canvas by devicePixelRatio and apply the inverse scale via CSS. This gives sharp text on Retina screens without changing the coordinate system used by the rest of the rendering pipeline:

typescript
const dpr = window.devicePixelRatio || 1;
canvas.width = logicalWidth * dpr;
canvas.height = logicalHeight * dpr;
canvas.style.width = `${logicalWidth}px`;
canvas.style.height = `${logicalHeight}px`;
ctx.scale(dpr, dpr);

RTL and wrapping. Right-to-left text and cell text wrapping are handled by a custom text layout pass that runs before painting. It is not as complete as the browser's layout engine, but it covers the cases that matter for spreadsheet content.

8 Packages, Clean Boundaries

The canvas system is not a monolith. It is decomposed into 8 packages plus 6 drawing sub-packages:

| Package | Responsibility | |---------|---------------| | engine | Core canvas abstraction, render loop, coordinate transforms | | grid-renderer | Cell painting, text layout, borders, merge regions | | grid-canvas | Interactive grid: scroll, selection, cell editing | | drawing-canvas | Drawing layer for shapes, images, charts | | overlay | Selection highlights, drag handles, resize indicators | | spatial | R-tree spatial index for hit-testing | | lab | Development sandbox for visual testing |

The drawing sub-packages (shapes, ink, smartart, wordart, geometry, images) mirror the OOXML drawing specification, making round-trip fidelity with XLSX files straightforward.

Each package has its own test suite. grid-renderer can be tested with a mock canvas context. spatial is pure data structure code with no rendering dependency. overlay can be developed independently from the cell grid. This separation means a change to selection rendering does not require retesting cell painting.

The Trade-Off

Canvas rendering is not free. You lose native text selection, built-in accessibility, and browser DevTools element inspection. Each of these requires explicit work to replicate.

But for a spreadsheet -- where you need to render tens of thousands of cells at 60fps, handle complex overlapping layers, and maintain pixel-precise control over layout -- canvas is not just viable. It is the only architecture that works.