Building a high-performance spreadsheet renderer

Building a spreadsheet renderer.

At Quadratic, we set out to build a spreadsheet that can handle millions of cells while maintaining butter-smooth 60fps panning and zooming. This meant rethinking how spreadsheets traditionally render their grids. In this post, we'll explore why we chose PixiJS with MSDF font rendering over HTML, and how we wrote custom shaders to dramatically reduce render passes.

The problem with HTML rendering

Traditional web-based spreadsheets render each cell as a DOM element. This approach has fundamental scaling issues:

  • DOM overhead: Each cell requires a DOM node with styles, event handlers, and layout calculations. The browser's DOM was designed for documents, not for data-dense grids with thousands of visible elements.
  • Reflow costs: Moving thousands of DOM elements during panning triggers expensive browser layout recalculations. Even with transform optimizations, the browser still needs to composite and manage each element.
  • Draw call explosion: Each styled element with unique properties can become a separate draw call. The browser's rendering pipeline isn't optimized for batching thousands of similar rectangles with text.
  • Limited control: With DOM rendering, you're at the mercy of the browser's rendering pipeline. You can't control draw order, batching, or implement custom optimizations.

When you're dealing with millions of cells, even having 10,000 visible on screen at once would create 10,000+ DOM elements. The browser simply wasn't designed for this use case.

The game engine approach

We made a deliberate choice to treat the spreadsheet grid like a game—using WebGL for rendering. As we describe in our architecture docs:

"The Quadratic Grid is built on WebGL using PixiJS. This allows us to render a high-performance grid with all your data, where you can quickly pan and zoom. By basically using a game engine to render the grid, this gives us a high level of control over what is drawn to the grid and our render pipeline."

PixiJS gives us:

  • GPU-accelerated rendering via WebGL
  • Batching infrastructure to minimize state changes
  • Viewport management with pixi-viewport for smooth pan/zoom
  • A mature rendering pipeline we can extend with custom shaders

MSDF: Crisp text at any scale

When you render text in WebGL, you typically have two options: draw text to a canvas and upload it as a texture (slow, blurry when scaled), or use bitmap fonts (pixelated at different sizes). Neither works well for a spreadsheet that zooms from 1% to 1000%.

We use Multi-channel Signed Distance Fields (MSDF), a technique pioneered for game development that stores font glyphs as distance field textures. The key insight is that instead of storing pixel colors, we store the distance from each pixel to the nearest edge of the glyph.

Here's our fragment shader that renders MSDF text:

void main(void) {
  vec4 texColor = texture2D(uSampler, vTextureCoord);

  // MSDF: compute median of RGB channels
  float median = texColor.r + texColor.g + texColor.b -
                  min(texColor.r, min(texColor.g, texColor.b)) -
                  max(texColor.r, max(texColor.g, texColor.b));

  // Combine with SDF (alpha channel) for better quality
  median = min(median, texColor.a);

  // Convert distance to screen pixels
  float screenPxDistance = uFWidth * (median - 0.5);
  float alpha = clamp(screenPxDistance + 0.5, 0.0, 1.0);

  gl_FragColor = vec4(0.0, 0.0, 0.0, alpha);
}


The magic is in the uFWidth uniform, which we calculate based on the current zoom level:

uFWidth = distanceFieldRange × fontScale × screenScale


This means:

  • At any zoom level, text remains crisp (within the limits of the distance field resolution)
  • Anti-aliasing is automatic—the distance field naturally produces smooth edges
  • One texture atlas serves all sizes—we don't need different bitmap fonts for different scales

We generate our font atlases using msdf-bmfont-xml, creating textures from OpenSans with fallbacks to Noto Sans for extended Unicode support (currency symbols, etc.). A single 42px atlas font renders well across our supported zoom range of 0.01x to 10x.

Custom shaders: Reducing render passes

A naive approach to rendering cells would require multiple draw calls per cell: one for the background, one for the text, one for borders, etc. With millions of cells, this explodes into millions of draw calls.

We wrote custom shaders to batch similar operations together. Our key optimization: separate shaders for tinted vs. non-tinted text.

Most cell text is black. By having a dedicated non-tinted shader, we avoid sending color data (4 floats per vertex × 4 vertices per glyph) when it's not needed:

Non-tinted shader (for black text):

// Output is always black with computed alpha
gl_FragColor = vec4(0.0, 0.0, 0.0, alpha);


Tinted shader (for colored text, links):

// Output uses per-vertex color
gl_FragColor = vec4(vColors.rgb, vColors.a * alpha);


This reduces vertex buffer size by ~33% for the common case of black text.

Our rendering pipeline uses four primary shader types:

  1. Triangle shader — batched cell backgrounds and fills
  2. Line shader — grid lines and borders
  3. Text shader — MSDF text with dynamic scaling
  4. Sprite shader — emoji and images

Each shader is designed for maximum batching. A single frame might render 100,000 visible cells with only 10-50 draw calls.

Spatial hashing for efficient updates

When you batch thousands of glyphs into a single vertex buffer, you get great rendering performance—but what happens when the user edits a cell? Recalculating geometry for every visible cell on each keystroke would be painfully slow.

This is where spatial hashing becomes essential. We organize cells into rectangular regions (15 columns × 30 rows per hash), and each hash maintains its own vertex buffers. When a cell is edited, we only recalculate the geometry for cells within that single hash—not all visible cells.

Before spatial hashing:
  Edit cell → Recalculate ALL visible text geometry → Slow updates

After spatial hashing:
  Edit cell → Recalculate only that hash's geometry → Fast updates


This transforms editing from an O(visible cells) operation to an O(cells per hash) operation—typically a 100x+ reduction in work.

The same structure also enables:

  • Viewport culling: Only hashes intersecting the viewport are rendered
  • Progressive loading: Visible hashes are prioritized, with padding for smooth scrolling
  • Memory management: When memory exceeds 500MB, distant hashes are unloaded LRU-style

Within each hash, all text with the same font/style is batched into shared vertex buffers. When a buffer exceeds 15,000 vertices (WebGL limits), we split into multiple LabelMeshEntry objects—but each is still a single draw call.

Multi-threaded architecture

To maintain 60fps, we can't do layout calculations on the main thread. We use a multi-threaded architecture:

  1. Core Worker (Rust/WASM): Holds the actual spreadsheet data and performs all operations on it (formulas, code execution, imports, etc.)
  2. Render Worker (TypeScript): Performs text layout and generates vertex buffers for the GPU
  3. Main Thread: Runs PixiJS, handles user input, executes final GPU rendering

The communication flow is key to efficient updates:

  1. Renderer requests data: The render worker asks core for cell data in the visible viewport plus padding
  2. Core tracks the viewport: Core maintains a copy of the current viewport via SharedArrayBuffer, so it always knows what's on screen
  3. Operations complete: When core finishes an operation (edit, formula recalc, etc.), it checks if any changed cells fall within the rendered viewport
  4. Targeted updates: If rendered cells changed, core sends only those updated cells to the renderer, which re-renders just the affected hashes

This means most operations don't trigger any re-rendering. Only changes visible on screen cause GPU work.

Data flows through the system using zero-copy Transferable ArrayBuffers. The render worker generates complete vertex/index buffers that transfer to the main thread without copying.

// Zero-copy transfer to main thread
postMessage(
  { vertices, uvs, indices, colors },
  [vertices.buffer, uvs.buffer, indices.buffer, colors.buffer]
);


The results

With this architecture, Quadratic achieves:

  • 60fps panning and zooming across millions of cells
  • Crisp text at any zoom level (0.01x to 10x)
  • 10-50 draw calls per frame regardless of visible cell count
  • Progressive loading so large files open instantly
  • Memory efficiency with LRU hash eviction

We believe that for a tool to become joyful to use, it has to run very smoothly. By treating the spreadsheet as a game engine rather than a DOM application, we've achieved the performance needed for a truly responsive experience.

Quadratic logo

Get started for free

The AI spreadsheet built for speed, clarity, and instant insights, without the pain.

Try Quadratic free