← Back to blog
Pretext by Cheng Lou: How to Update Text Layout Without getClientBoundingRect (Deep Dive)
PretextCheng Loutext layoutDOM performancegetClientBoundingRectlayout thrashingReactincremental renderingresponsive UIrendering algorithmsCSSfont metricscanvas measureText

Pretext by Cheng Lou: How to Update Text Layout Without getClientBoundingRect (Deep Dive)

By Imran Khan·Apr 01, 2026·18m read

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:

  1. The performance problem Pretext is attacking (and why getClientBoundingRect is so costly in reactive UIs)
  2. The fundamental algorithmic idea: calculating text spacing and layout from measurable primitives
  3. How you’d implement similar techniques today to build responsive, reactive interfaces with fewer rerenders and fewer layout reads
  4. 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:

  1. JS runs, mutates DOM/styles
  2. Style recalculation
  3. Layout (compute geometry)
  4. Paint
  5. 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 cached
  • prefixWidths for fast x queries
  • lineBreaks computed 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:

  1. Document model: text content and attributes
  2. Measurement cache: mapping from (fontKey, token) → width
  3. Layout model: line breaks and x/y coordinates derived from widths + container width
  4. 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 letterSpacing yourself: add (n-1)*letterSpacing for a token of length n (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:

  1. Avoiding DOM layout/paint churn by not touching most nodes
  2. 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 requestAnimationFrame loops

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.