BM
Bhavik Mehta
Contact Me
Back to Blog
{ 07 } — Engineering

Pretext: Faster Web Text Without DOM Reflow

2026-04-2110 min read
#Web Performance#JavaScript#Engineering

Why Measuring Text in a Browser Is Surprisingly Painful

Every time you want to know how tall a paragraph of text will be, the browser has to do something called a layout reflow. It stops what it's doing, walks through the entire document tree, recalculates positions, figures out line breaks, measures fonts, and then hands you a number. On a small page with a handful of elements, this is fine. You'd never notice.

But imagine building something more demanding — a virtualized list of ten thousand items, an AI chat interface where new text streams in every 50ms, or a masonry grid that repositions cards as content loads. In those cases, triggering a layout reflow for every text measurement is the equivalent of shutting down a factory floor to count inventory, then restarting it, then shutting it down again, hundreds of times per second.

That's the problem Pretext solves.

Why Layout Reflow Is So Expensive

The browser's rendering pipeline has a well-known performance trap called forced synchronous layout (also called layout thrashing). It happens when JavaScript reads a layout-dependent property — offsetHeight, scrollWidth, getBoundingClientRect() — immediately after mutating the DOM. The browser cannot return a stale value, so it flushes its pending style and layout work synchronously, right then, blocking the main thread until the entire layout tree is resolved.

In a tight loop — measuring fifty card heights to compute a masonry column arrangement, for example — this flush happens fifty times in a single frame. Each forced reflow can take 2–10ms depending on DOM complexity. At 60fps, your entire frame budget is 16ms. Text measurement alone can blow through it.

DevTools calls these out as long "purple" tasks in the Performance panel. The standard advice is "batch your reads and writes" — but that only helps when you control the render cycle. When text content is generated dynamically by an AI model or shaped by user input, you can't batch what you don't know ahead of time.

Released in March 2026 by Cheng Lou — the engineer behind several influential React and ReScript projects, and more recently a key engineer at Midjourney — Pretext is a 15KB zero-dependency TypeScript library that measures and lays out text without ever touching the DOM. It racked up 14,000 GitHub stars and 19 million views on X within 48 hours of release, which is a reasonable signal that a lot of developers had been quietly suffering with this exact problem.

The Mental Model: Canvas as Ground Truth

To understand how Pretext works, it helps to understand what the browser is actually doing when it measures text.

Fonts are not pixel grids. Every character in a font is a mathematical description of a shape, and rendering it at a given size requires the browser to consult the font file, apply hinting rules, resolve kerning pairs, handle unicode segmentation, and then figure out exactly how many pixels wide the character is. This is genuinely complex — different operating systems handle some fonts differently, CJK characters have different line-breaking rules, and even something as simple as "how wide is this word?" depends on which font is loaded and at what size.

The browser already has a fast path for measuring this — the Canvas 2D API. When you draw text on a canvas, the browser uses canvas.measureText() to figure out glyph widths. This call is cheap. It does not touch the DOM layout system. It goes straight to the font engine and returns a width.

Pretext's insight is: use canvas.measureText() as the source of truth for glyph metrics, cache those measurements during a preparation step, and then handle all line-breaking logic yourself in pure JavaScript arithmetic. Once you have the widths of every text segment cached, calculating line breaks and paragraph heights is just addition and comparison — no DOM involvement at all.

This is the core abstraction: separate measurement from layout, and do the layout yourself.

How the API Works

Pretext exposes two main functions: prepare() and layout().

import { prepare, layout } from "@chenglou/pretext";
 
const handle = prepare(
  "Hello, world! This is a sample paragraph.",
  "16px Inter",
);
const result = layout(handle, 320, 24);
 
console.log(result.height); // total height in pixels
console.log(result.lineCount); // number of lines

prepare(text, font, options) does the expensive work upfront. It normalizes whitespace, segments the text into units (words, CJK characters, punctuation clusters), applies Unicode line-breaking rules, and measures each segment's width using the canvas API. The result is an opaque handle — a compact data structure that stores all the cached widths.

The critical rule: call prepare() once per unique text + font combination, then reuse the handle. Calling it again for the same text defeats the entire point.

layout(handle, maxWidth, lineHeight) takes that handle and a container width, then runs pure arithmetic to simulate line wrapping. There are no DOM queries, no getBoundingClientRect calls, no style recalculations. It's just numbers. This is why it's fast enough to run hundreds of times per frame without dropping below 60fps.

For more advanced use cases — rendering text onto a canvas, positioning text in an SVG, or server-side layout calculations — Pretext also exposes a line-by-line API that gives you the exact start and end character positions of every line at a given width. This is the primitive you'd need to build a text editor, a PDF renderer, or a custom virtualized list.

Offloading prepare() to a Web Worker

Because prepare() is synchronous and proportional to text length, running it on the main thread for large documents creates a noticeable jank spike — even if that spike is a fraction of what DOM reflow would cost. The right pattern for content-heavy applications is to offload prepare() to a dedicated Web Worker, then postMessage the serialized handle back to the main thread.

// worker.ts
import { prepare } from "@chenglou/pretext";
 
self.onmessage = (e) => {
  const { id, text, font } = e.data;
  const handle = prepare(text, font);
  self.postMessage({ id, handle });
};

The main thread posts text segments to the worker as they arrive, receives handles back asynchronously, and calls layout() synchronously when it needs pixel dimensions. layout() is pure arithmetic — it has no canvas dependency and runs in microseconds, so it stays on the main thread without issue. This split keeps the critical rendering path clear while amortizing prepare() cost across idle time and off-thread execution.

For React applications using TanStack Virtual or react-window, this pattern means you can pre-compute item heights for the entire dataset in a worker before the list mounts, handing the virtualizer exact sizes rather than estimates — eliminating the size estimation callbacks these libraries rely on today.

What Actually Gets Fast

The claimed performance improvement is 300–600x over traditional DOM-based measurement. To make that concrete: if measuring a paragraph height via DOM reflow takes 3ms, Pretext measures the same paragraph in roughly 5–10 microseconds after the prepare() call is done.

That difference unlocks things that were previously architecturally impractical:

Virtualized lists with variable-height text. Most virtualization libraries estimate item heights to avoid measuring upfront. Estimates cause layout jumps when the real height is different. With Pretext, you can calculate exact heights for all items before rendering any of them — no estimates, no jumps.

AI streaming interfaces. When a language model streams text token-by-token, the UI needs to continuously reflow the message bubble. With DOM-based measurement, this means triggering a layout reflow every 50ms or faster. With Pretext, you recalculate layout on every token in memory and only push DOM updates when the line count actually changes.

Masonry and complex grid layouts. Calculating a masonry layout requires knowing the height of every card before placing any of them. If those cards contain text, you'd traditionally have to render them all off-screen, measure them, then reposition. With Pretext, you measure in memory first.

Server-side text layout. Because Pretext's layout() is pure arithmetic, it can run in Node.js (with a canvas polyfill for the prepare() step) or in a web worker. You can compute text layout on the server for PDF generation, email rendering, or static site generation without spinning up a headless browser.

The Limitations Worth Knowing

Pretext is not a drop-in replacement for the browser's layout engine. It models a specific, useful subset of CSS text behavior and explicitly does not model the rest.

What it handles: white-space: normal and white-space: pre-wrap, word-break: normal and word-break: keep-all, standard Unicode line-breaking rules across Latin, CJK, and most other scripts.

What it does not model: letter-spacing, font-feature-settings, ligature-dependent width changes, font-variation-settings overrides, and text-indent. If your text uses any of these properties, Pretext's measurements will be off.

The system-ui problem. On macOS, system-ui resolves to different physical fonts depending on context, and the canvas API may resolve it differently than the DOM. Pretext's documentation flags this explicitly — always use a named font like Inter or Roboto rather than system-ui if you need pixel-accurate results.

prepare() is not free. The initial preparation step, which involves canvas measurement, is synchronous and proportional to the number of unique text segments. For very long documents, you'd want to run prepare() in a web worker to avoid blocking the main thread. The layout() call is fast enough to run synchronously, but prepare() on a 50,000-word document would cause a noticeable pause on the main thread.

Browser support dependency. Pretext requires Intl.Segmenter for Unicode segmentation — this is the standard API for splitting text into grapheme clusters, words, and sentences as defined by the Unicode locale data. It is supported in all modern browsers as of 2024, but if you need to support older environments (or legacy React Native WebView contexts) you'll need a polyfill. The library also depends on the Canvas 2D context being available — server-side usage requires a canvas polyfill like node-canvas for the prepare() phase.

Silent measurement drift. If the font referenced in prepare() has not yet finished loading — for example, a Google Font that's still in-flight — the canvas will fall back to a system font for measurement. The resulting handle will encode widths for the fallback, not the intended typeface. When the real font loads and the DOM renders with it, Pretext's cached measurements will be wrong, causing layout mismatches that are hard to reproduce because they depend on network timing. The fix is to always call prepare() inside a document.fonts.ready promise or after confirming FontFaceSet load status.

When to Reach for Pretext (and When Not To)

Pretext is the right tool when:

  • You need to know text dimensions before rendering — for layout calculation, virtualization, or pre-positioning
  • Your text updates faster than DOM reflow can keep up with (streaming, real-time collaboration, animation)
  • You're rendering text outside the DOM — canvas, SVG, WebGL, or server-side

It is not the right tool when:

  • Your text uses CSS properties Pretext doesn't model (letter-spacing, ligatures, variable fonts with axis-based width changes)
  • You need rich styled text with mixed inline elements — Pretext measures uniform font configs, not mixed spans
  • You only measure text once or twice at page load — the preparation overhead won't pay for itself, and a single getBoundingClientRect call is simpler

The practical test: if you're calling a DOM measurement API inside a loop, inside a scroll handler, or on every render cycle, Pretext will likely make that code significantly faster and more architecturally clean. If you're measuring once to align two elements, use the browser.

Why This Matters Beyond the Numbers

The deeper implication of Pretext is architectural. Right now, most frontend frameworks treat text layout as something that only the browser can do. This assumption shapes how we build interfaces — we render first, then measure, then adjust, which creates the flash-of-wrong-layout problems every frontend developer knows well.

Pretext is a step toward decoupling text layout from the browser's rendering pipeline entirely. When you can calculate exact text dimensions in memory, before any rendering happens, you can build interfaces that know their own geometry upfront. That changes how you approach virtualization, animation, server-side rendering, and the growing category of AI-driven interfaces where text content is dynamic and unpredictable.

The library is also a proof of concept for a broader architectural shift: userland layout. Right now, the web platform treats text layout as a black box inside the browser engine. You feed it content and CSS, and it hands you a rendered result you can query after the fact. Pretext demonstrates that a sufficiently accurate userland reimplementation — one that uses the browser's own font engine as a ground-truth oracle — can match that output with enough fidelity to be production-useful, while running an order of magnitude faster.

That has implications for server-side rendering quality, for native-feeling mobile web apps that need sub-frame layout responsiveness, and for the emerging class of generative UI systems where an AI model is constructing layout dynamically. If the layout engine lives in JavaScript rather than the browser, it can be modified, extended, and reasoned about in ways the browser's C++ internals cannot.

Fourteen thousand stars in 48 hours is not a reaction to a clever optimization. It's a reaction to a library that changes what's possible — and raises the question of what else we've been leaving to the browser that we could do better ourselves.

References


Disclaimer: This blog post was researched, written, and published with the assistance of AI. The content reflects general information on the topic and does not represent the personal opinions, beliefs, professional advice, or endorsements of Bhavik Mehta. Nothing in this post should be construed as legal, financial, technical, or professional advice. Readers should independently verify any information before acting on it.