Pretext by Cheng Lou: How to Update Text Layout Without getClientBoundingRect (Deep Dive)
An engineering-focused explanation of Cheng Lou’s Pretext concept: measuring text spacing algorithmically to avoid expensive DOM reads, enabling responsive updates without full rerenders, and how it compares to React’s standard rendering model.
Pretext (by Cheng Lou) is one of those deceptively simple ideas that feels “obvious in hindsight” once you see it: if the browser is expensive to ask about layout, stop asking. Instead, predict layout—especially for text—by doing the math yourself, and treat the DOM as an output device rather than an oracle.
That framing matters because a huge amount of UI “work” on the web isn’t actually computing what to render; it’s waiting on the browser to answer questions like:
- “Where is this element now?” (
getBoundingClientRect,offsetTop, etc.) - “How big did it end up?” (layout-dependent reads)
- “Did this text wrap?” (often discovered only after layout)
Those reads force the browser to flush its pending style and layout work, which can cascade into layout thrashing if you interleave reads and writes during an interaction.
Pretext’s core bet is: for many interfaces—especially text-heavy or typography-driven ones—you can build a predictive model that’s cheap enough to run frequently, accurate enough to look right, and structured enough to update incrementally.
This post dives into:
- The performance problem Pretext is attacking (and why
getClientBoundingRectis so costly in reactive UIs) - The fundamental algorithmic idea: calculating text spacing and layout from measurable primitives
- How you’d implement similar techniques today to build responsive, reactive interfaces with fewer rerenders and fewer layout reads
- How this compares to React and modern standards (and why it’s not “React vs Pretext” so much as “rendering model choices”)
Why DOM layout reads are expensive (and why they show up everywhere)
The browser rendering pipeline is roughly:
- JS runs, mutates DOM/styles
- Style recalculation
- Layout (compute geometry)
- Paint
- Composite
When you do a layout read—like getBoundingClientRect()—the browser must ensure layout is up-to-date right now. If you previously made writes that invalidate layout (changing classes, styles, content), the browser may need to synchronously run style + layout before it can return the rectangle.
That’s not inherently evil if you do it once. It becomes a problem when UI frameworks or app code do it repeatedly in response to:
- scroll
- pointermove / drag
- input typing
- animations
- collaborative editing updates
- autosizing components
In a “reactive” UI, you often want to update something as the user types or drags. But if each update triggers DOM writes and then a DOM read (to figure out where things ended up), you get the infamous pattern:
- write → read → write → read …
That pattern kills smoothness because it prevents the browser from batching work.
Pretext proposes: don’t read layout. Maintain a model that lets you compute where things should be.
What Pretext is really modeling: text as measurable geometry
Text layout feels magical because the browser has an enormously complex layout engine: fonts, kerning, ligatures, shaping, bidi, line-breaking rules, hyphenation, etc.
Pretext narrows the problem to something tractable: predict the geometry of text runs well enough to place elements, selections, highlights, cursors, and to drive incremental updates—without repeatedly asking the DOM.
At the heart of this is a very old graphics trick:
If you can measure the width of text in a particular font at a particular size, you can compute where each glyph (or cluster) lands along a line.
Once you can do that, a lot becomes possible:
- Place a caret at a character index without DOM reads
- Draw selection rectangles without DOM reads
- Compute line wraps without DOM reads (given a container width)
- Update only the parts that changed when text changes
This is conceptually closer to building a “mini layout engine” for your app’s text needs.
The fundamental algorithm: calculating text spacing
Let’s unpack “text spacing” in practical terms.
Given:
- a string
S - a font configuration (family, size, weight, letter-spacing, etc.)
- a maximum line width
W
We want to compute:
- line breaks: where each line starts/ends in the string
- per-character (or per-grapheme) x-positions within a line
- the total height (number of lines × line-height)
- geometry for spans/ranges: rectangles for selections, highlights, annotations
Step 1: Measure widths in a consistent coordinate system
The typical tool is canvas text metrics:
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
function setFont({ family, size, weight }) {
ctx.font = `${weight} ${size}px ${family}`;
}
function measure(text) {
return ctx.measureText(text).width;
}
This avoids layout reads entirely: it’s pure JS/graphics measurement. It’s not free, but it’s usually far cheaper and more predictable than forcing DOM layout, and it can be cached aggressively.
Step 2: Turn “measureText” into prefix widths
A naive approach to per-character positioning is:
- measure each prefix
S.slice(0, i)and take its width
But that’s O(n²). Pretext-style systems instead build a structure that allows efficient queries.
A pragmatic baseline is:
- split text into chunks (words, graphemes, or fixed-size blocks)
- measure each chunk once
- keep cumulative sums
For example, if you tokenized into graphemes g0..g(n-1) with widths w0..w(n-1) then the x-position of grapheme i is:
x(i) = sum(w0..w(i-1))
That gives caret placement and range geometry.
In practice, you often do it at the word level first (for line breaking), then refine within the line.
Step 3: Line breaking by accumulating widths
The simplest greedy line break algorithm:
- keep adding tokens until adding the next token would exceed
W - then break line
This is not TeX-quality line breaking, but it’s frequently “good enough” for UI text that prioritizes responsiveness.
Pseudo:
function layoutTokens(tokens, tokenWidths, maxWidth) {
const lines = [];
let lineStart = 0;
let x = 0;
for (let i = 0; i < tokens.length; i++) {
const w = tokenWidths[i];
if (x + w > maxWidth && i > lineStart) {
lines.push({ start: lineStart, end: i, width: x });
lineStart = i;
x = 0;
}
x += w;
}
lines.push({ start: lineStart, end: tokens.length, width: x });
return lines;
}
Where “tokens” could be words plus spaces (important: spaces must be measured/represented too).
Step 4: Convert line layout into absolute geometry
Once you have lines, each line has:
- y-position =
lineIndex * lineHeight - x-positions from cumulative sums of token widths within that line
Selections become rectangles:
- for a range
[a, b)you find which lines it intersects - compute startX and endX within each line via prefix sums
- emit rectangles
{x, y, width, height}
All without DOM reads.
Where “text spacing” becomes the key abstraction
The interesting part is how you represent spacing so updates are cheap.
Instead of “the DOM decides spacing”, you store a model like:
widthOf(token)measured or cachedprefixWidthsfor fast x querieslineBreakscomputed from widths and container width
Then updates become local:
- insert/delete a piece of text → only remeasure affected tokens
- container width changes (responsive) → recompute line breaks but reuse token widths
- font changes → invalidate caches and recompute
This is a classic separation:
- measurement (token width cache)
- layout (line breaking)
- render (apply transforms / draw overlays / update minimal DOM)
Implementation approach: building a “Pretext-like” engine today
To implement this technique for responsive and reactive interfaces, you want to be disciplined about one thing:
Your UI state should not depend on reading the DOM during interaction.
Instead, compute geometry from your model and only write to the DOM (or a canvas layer).
A workable architecture has four layers:
- Document model: text content and attributes
- Measurement cache: mapping from
(fontKey, token)→ width - Layout model: line breaks and x/y coordinates derived from widths + container width
- View: minimal DOM nodes for text, plus an overlay for selections/carets/annotations
Measuring efficiently: caching and tokenization
If you measure every character individually with measureText, you might still be fine for small text—but for editors or complex pages you’ll need caching.
A common approach:
- Tokenize into words + whitespace tokens
- Cache token widths by
(fontKey, tokenString) - Keep an array of token widths per paragraph
Example cache key:
const fontKey = `${weight}-${size}-${family}-${letterSpacing || 0}`;
const key = `${fontKey}::${token}`;
Then:
- when text changes, re-tokenize only the changed region (rope/piece-table helps)
- remeasure only new/changed tokens
Handling letter-spacing, ligatures, kerning, and accuracy tradeoffs
This is where the “deep” part lives: canvas measurement is not always a perfect mirror of DOM text rendering, especially with:
letter-spacing- OpenType features / ligatures
- complex scripts (shaping)
- font fallback (missing glyphs)
- subpixel rounding differences
Pretext’s philosophy is pragmatic: you aim for a model that’s consistent and stable rather than perfectly identical to every browser corner case.
Practical strategies include:
- Disable ligatures for deterministic caret math (
font-variant-ligatures: none;) when building an editor-like UI - Apply
letterSpacingyourself: add(n-1)*letterSpacingfor a token of lengthn(but be careful with grapheme clusters) - For complex scripts, measure at larger chunk sizes and avoid per-glyph caret fidelity unless you truly need it
If you need near-perfect caret positions for every script, you eventually end up relying on browser shaping/layout anyway. The key is choosing where your product sits on that spectrum.
Computing updates without rerendering everything
“Without entire rerenders” can mean two different things:
- Avoiding DOM layout/paint churn by not touching most nodes
- Avoiding framework-level re-render work by doing fine-grained updates
A Pretext-like system typically updates only:
- the changed text nodes (often in a single paragraph)
- overlay geometry (selection/caret), which can be a single absolutely-positioned element updated via transform
- scroll position / viewport windowing
For example, for selections you can render rectangles in an overlay:
<div class="editor">
<div class="text-layer"></div>
<div class="selection-layer"></div>
</div>
Then update .selection-layer by setting its children’s style attributes (or drawing into a <canvas>). No measuring the DOM, no reading rectangles.
Responsiveness: reacting to container width changes
Instead of reading line boxes from the DOM, you:
- observe container size (e.g.
ResizeObserver) - recompute line breaks using the cached token widths
- update only what’s necessary
Key point: ResizeObserver tells you width/height without forcing you into per-node layout reads. You still must be careful not to cause feedback loops, but this is usually far cleaner than repeatedly calling getBoundingClientRect on many children.
A minimal example: greedy line wrap with cached measurements
Below is a simplified sketch of what a “predictive layout” loop looks like. (It’s not a full editor; it’s the core idea.)
class TextMeasurer {
constructor() {
this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d");
this.cache = new Map();
this.fontKey = "";
}
setFont(cssFontString) {
this.ctx.font = cssFontString;
this.fontKey = cssFontString;
}
width(token) {
const key = this.fontKey + "::" + token;
const hit = this.cache.get(key);
if (hit != null) return hit;
const w = this.ctx.measureText(token).width;
this.cache.set(key, w);
return w;
}
}
function tokenize(text) {
// keep spaces as tokens to preserve spacing
return text.match(/\S+|\s+/g) ?? [];
}
function layout(text, maxWidth, measurer, lineHeight) {
const tokens = tokenize(text);
const widths = tokens.map((t) => measurer.width(t));
const lines = [];
let line = { tokens: [], widths: [], width: 0 };
for (let i = 0; i < tokens.length; i++) {
const w = widths[i];
if (line.width + w > maxWidth && line.tokens.length > 0) {
lines.push(line);
line = { tokens: [], widths: [], width: 0 };
}
line.tokens.push(tokens[i]);
line.widths.push(w);
line.width += w;
}
lines.push(line);
// compute y positions
return lines.map((ln, idx) => ({
...ln,
y: idx * lineHeight,
}));
}
The “rendering” step can then simply create one DOM node per line (or per paragraph), and selection/caret uses computed x/y without ever reading DOM geometry.
This is the core: measure once, compute layout, write output.
How this enables reactive interfaces without full rerenders
The big win isn’t only speed—it’s control.
When you own the layout model, UI updates become local:
- When the user types:
- update document model
- re-tokenize + remeasure only affected tokens
- recompute line breaks for the affected paragraph (and maybe subsequent paragraphs if reflow changes height)
- update DOM nodes for that paragraph and overlay
- When the user drags a handle:
- update layout parameters (maxWidth, font size)
- recompute line breaks quickly from cached widths
- update transforms
This mirrors how high-performance editors (and many canvas-based UIs) work: treat layout as a computed artifact of state, not something to discover by probing the DOM.
It’s also how you avoid the “entire rerender” feeling: your system’s dependency graph is not “whole component tree”, it’s “only the region of the text and geometry that changed”.
Comparing this approach to React (and modern UI standards)
It’s tempting to frame this as “React is slow, Pretext is fast”, but that misses the nuance. React is primarily:
- a declarative UI library
- with a reconciliation algorithm for updating the DOM efficiently
- not a layout engine
React can avoid unnecessary DOM writes very well. But React does not remove the cost of:
- browser layout
- layout reads forcing synchronous flushes
- text measurement needs for editor-like experiences
React’s standard model: render → commit → browser lays out
In typical React:
- state changes
- component re-renders (virtual tree)
- React commits DOM updates
- browser recalculates layout/paint later
If you then need geometry (caret position, highlight rectangles), you often do it in an effect:
useLayoutEffect(() => ref.current.getBoundingClientRect() ...)
That’s exactly the expensive pattern Pretext is trying to eliminate, especially if it happens frequently.
React can mitigate some of it:
- batching updates
- avoiding unnecessary reads
- using
requestAnimationFrameloops
But if your product fundamentally needs geometry every keystroke, DOM reads become a bottleneck.
React can still use the Pretext idea
Pretext is not inherently anti-React. You can absolutely:
- keep a predictive text layout model in state (or external store)
- render text and overlays from that model
- avoid layout reads almost entirely
React then becomes the “view layer” for a layout engine you own.
Where teams get into trouble is when React is expected to “solve text layout” through DOM measurement loops. React won’t, because the browser owns layout.
Fine-grained reactivity vs component rerenders
Pretext’s mindset aligns more with:
- fine-grained reactive systems (Solid, Svelte’s compiled updates, signals)
- editor architectures (ProseMirror, Slate, Lexical)
- retained-mode renderers (Pixi, Skia via Canvas/WebGPU)
That said, even fine-grained frameworks can’t escape layout flushes if they rely on DOM geometry reads during interaction. Pretext’s differentiator is: geometry is computed, not queried.
Tradeoffs: where React’s approach is better
Pretext-like approaches shine when:
- you need tight interaction loops (typing, selection, dragging)
- you need predictable performance under load
- you can constrain typography/rendering features enough to model them
React’s standard approach is better when:
- layout is complex and should be delegated to CSS (grids, flex, responsive content)
- text fidelity across scripts/fonts is critical and you don’t want to approximate
- development speed and maintainability outweigh micro-optimizations
- you don’t need per-keystroke geometry computations
In other words: for many websites, Pretext is overkill. For editors, annotations, rich-text experiences, and typography-driven tools, it’s compelling.
Practical guidance: when (and how) to adopt this in real products
If you want the benefits without building a full text engine, a practical incremental path is:
1) Stop measuring during the hot path
If you currently do this:
- on input → update DOM → read
getBoundingClientRect→ adjust something
Try to restructure:
- on input → update model → compute geometry → write styles
Even if your geometry is approximate at first, this often produces a smoother UI.
2) Use a dedicated overlay for “interactive geometry”
Selections, carets, highlights, comment anchors—these are typically what cause measurement loops.
Render them in an overlay whose positions come from your model. Avoid DOM reads to “find” where the text is.
3) Cache text measurements aggressively
Most UIs reuse the same tokens constantly (spaces, common words, repeated labels). A cache can turn measurement into near-zero overhead after warmup.
4) Constrain typography to reduce complexity
If your UI is an editor-like surface, consider constraints that improve determinism:
- disable ligatures
- fixed line-height
- controlled font stack
- avoid mixed inline elements that change fonts mid-token unless you model them
5) Be honest about internationalization
If you need robust complex-script caret behavior, you may need deeper integration (or accept targeted DOM measurement for those cases). A hybrid system is common: predictive layout for the majority path, fallback to DOM for edge cases.