Back to Articles

Pretext: How Bypassing the DOM Makes Text Layout 100x Faster

[ View on GitHub ]

Pretext: How Bypassing the DOM Makes Text Layout 100x Faster

Hook

Every time you call element.offsetHeight on text content, you're potentially triggering a full browser layout pass that can block the main thread for milliseconds. What if you could get accurate multiline text measurements in microseconds instead?

Context

Browser layout engines are architectural marvels, but they're optimized for the general case: render everything once, change infrequently. Modern web applications violate this assumption constantly. Virtualized lists need to know item heights before rendering. Canvas-based editors need text measurements without DOM nodes. Responsive designs recalculate layout on every resize. Each measurement query—getBoundingClientRect(), offsetHeight, even getComputedStyle()—can force a synchronous layout recalculation across your entire document tree.

The traditional workaround is estimation: guess that each line is 20px tall, multiply by line count, accept that your scroll position will jump when the guess is wrong. Or render everything off-screen first, measure it, then render again in the right place—paying the layout cost twice. Pretext takes a radically different approach: treat text measurement as a pure data transformation problem. Measure the atomic building blocks once (individual grapheme clusters, words, break opportunities), cache those measurements, then recombine them using pure arithmetic whenever you need layout information. No DOM, no reflow, no blocking the main thread.

Technical Insight

Pretext's architecture splits text processing into two distinct phases that map beautifully to modern reactive rendering patterns. The prepare() function is expensive but runs once: it segments your text according to Unicode grapheme cluster boundaries, identifies break opportunities using UAX #14 line-breaking rules, measures each segment's width using Canvas 2D's measureText(), and returns an opaque handle containing these cached measurements. The layout() function is pure arithmetic: given a prepared text handle and a width constraint, it walks through the segments, accumulates widths until exceeding the limit, inserts breaks, and returns line information—all without touching the browser's layout engine.

Here's what this looks like in practice:

import { prepare, layout } from 'pretext';

// Expensive: do this once when content changes
const prepared = prepare(
  'The quick brown fox jumps over the lazy dog. This text will be laid out efficiently.',
  { font: '16px Inter', letterSpacing: 0 }
);

// Cheap: do this every resize, every scroll frame
const result = layout(prepared, 300); // 300px width
console.log(result.height); // Total height in pixels
console.log(result.lineCount); // Number of lines

// Resize? Just re-layout with new width - no re-measurement
const narrower = layout(prepared, 200);
console.log(narrower.lineCount); // More lines, calculated instantly

This separation is powerful because text content changes rarely compared to layout constraints. In a virtualized list, you prepare each item's text once when data loads, then call layout() hundreds of times during scrolling to calculate visible item positions. In a responsive design, you prepare on mount, then layout on every resize event. The cost model inverts: instead of every measurement triggering a potential full-document reflow, you pay once for segmentation and caching, then get O(1) height queries.

The library shines when you need per-line control. The layoutCursor() API gives you an iterator over individual lines, enabling patterns that are nearly impossible with DOM-based measurement:

import { prepare, layoutCursor } from 'pretext';

const prepared = prepare(longText, { font: '14px system-ui' });
const cursor = layoutCursor(prepared);

// Variable width per line - text flowing around a circular shape
const lines = [];
let y = 0;
while (!cursor.isDone()) {
  const radius = 150;
  const distanceFromCenter = Math.abs(y + 7 - radius);
  const width = 2 * Math.sqrt(radius * radius - distanceFromCenter * distanceFromCenter);
  
  const line = cursor.nextLine(width);
  lines.push({ text: line.text, y, width });
  y += 14; // line height
}

This code creates circular text flow—each line gets a different width based on its vertical position. Try doing that with CSS alone. The DOM would require creating individual elements per line, absolutely positioning each one, and manually inserting line breaks. Pretext makes it a straightforward loop because layout is just data transformation.

Under the hood, Pretext handles the gnarly Unicode complexity that makes text layout hard: grapheme cluster segmentation (treating 'é' as one unit even when it's 'e' + combining accent), bidirectional text (mixing English and Arabic), and comprehensive line-breaking rules (don't break between opening quote and word, prefer breaking after hyphens in some scripts). It implements these algorithms in pure JavaScript rather than relying on browser heuristics, giving consistent results across environments—including Node.js for server-side rendering or build-time layout calculation.

Gotcha

Pretext's biggest friction point is manual synchronization between measurement and rendering. When you call prepare(text, { font: '16px Inter' }), that font string must exactly match the CSS applied to your rendered text. Change your stylesheet to 16.5px or switch to a variable font with different metrics? Your measurements are now wrong, and you'll see text overflow or unexpected wrapping. There's no automatic detection of this mismatch—you'll discover it when your UI looks broken. This makes Pretext awkward in design systems where typography tokens change frequently, or when using CSS features like font-size: clamp() that resolve at runtime based on viewport.

The library also doesn't implement hyphenation, which is essential for professional typography in narrow columns. You can insert soft hyphens (\u00AD) manually before calling prepare(), but this requires either shipping a hyphenation library (like Hypher) and processing text before measurement, or accepting sub-optimal line breaks. Modern browsers do automatic hyphenation via hyphens: auto, but Pretext can't leverage that—it's pre-measuring outside the layout engine. For languages like German where words can be extremely long, this limitation means you might get worse results than native browser layout. The rich-inline helper similarly has constraints: it only handles white-space: normal behavior with flat inline markup. Need white-space: pre-wrap or nested <span> elements with different fonts? You're implementing that yourself or going back to DOM measurement.

Verdict

Use Pretext if you're building virtualized lists with multiline content (chat apps, feeds, document viewers), custom canvas/SVG text rendering (data visualizations, design tools, games), or complex dynamic layouts where text flows around shapes or changes width per line. It's particularly valuable when measurements happen frequently—during scroll, resize, or animation—where avoiding browser reflow directly improves frame rate. The prepare/layout split makes it excellent for server-side rendering scenarios where you can pre-calculate layouts at build time. Skip Pretext for static content where native browser layout works fine, or when your typography requirements include automatic hyphenation, complex nested inline formatting, or extensive use of CSS features that affect measurement (variable fonts, font-feature-settings). Also skip if your design system's typography changes frequently enough that keeping measurement parameters synchronized with CSS becomes a maintenance burden. For simple single-line measurements, Canvas measureText() is simpler; for full layout engine replacement, you're better off with a comprehensive solution that handles the edge cases Pretext intentionally skips.

// ADD TO YOUR README
[![Featured on Starlog](https://starlog.is/api/badge/developer-tools/chenglou-pretext.svg)](https://starlog.is/api/badge-click/developer-tools/chenglou-pretext)