Every time you call getBoundingClientRect() to measure a text element, your browser quietly does something brutal: it discards its entire layout tree, recalculates every position from scratch, and hands you back a number. This happens synchronously, on the main thread, and it blocks everything else.
For a static blog this doesn’t matter. For a streaming AI chat interface updating 60 times per second — or a virtualized list with hundreds of variable-height items — it is a wall.
Pretext is a 15 kb library by Cheng Lou (creator of React Motion, senior engineer at Midjourney) that eliminates this cost entirely. It measures and lays out multiline text using pure arithmetic, never touching the DOM after preparation.
The core idea in one paragraph
Pretext splits work into two phases. prepare() runs once per text+font combination: it uses Canvas’s measureText() API to measure every text segment, then caches the results. layout() runs as many times as you need: it computes line breaks and heights using only arithmetic on those cached widths — zero DOM reads, zero reflow. Width changes are free. Streaming updates are free. You pay the measurement cost once.
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare(
'Layout reflow is the silent performance killer.',
'16px Inter',
)
// First width — cheap
const { height, lineCount } = layout(prepared, 320, 24)
// Different width — still just arithmetic
const { height: h2 } = layout(prepared, 480, 24)
Why it matters now
The timing is not accidental. AI applications that stream text token by token need to resize bubbles on every frame. Virtualized lists need to predict heights before elements exist in the DOM. Masonry layouts need to know heights before placing cards. All of these patterns were either janky or required workarounds involving hidden off-screen containers.
Pretext solves the underlying problem rather than working around it: treat the browser’s font engine as an oracle during preparation, then never ask it again.
The numbers
| Operation | Cost |
|---|---|
prepare() for 500 blocks | ~19 ms (same as one DOM pass) |
layout() per call | ~0.09 ms |
getBoundingClientRect() per call | ~0.04 ms — but forces reflow when layout is dirty |
| Full reflow on dirty tree | 10–100+ ms depending on page complexity |
The real win is not the per-call speed — it is that layout() never dirties the layout tree, so it never triggers the cascade.
What follows
The rest of this piece works from the problem to a running demo: what layout
reflow actually is and why getBoundingClientRect() is expensive, then the
two-phase model that avoids it — the Canvas oracle, the arithmetic layout, and
the full API. After that it stops being theory: a streaming AI chat whose bubble
heights are measured with pretext, and an editorial layout where text flows
around a dancer and is recomputed every frame.
Want to see it before reading any of it? Text flowing around a dancing figure, recomputed every frame, running in this page — no setup, no install. Jump to the Matteflow demo
The reflow tax: why measuring text is expensive
What layout reflow is, exactly which reads trigger it, and why text measurement is always a dirty read.
Read on its own: The reflow tax: why measuring text is expensiveWhat is layout reflow?
When a browser renders a page, it builds two trees: the DOM tree (what elements exist) and the layout tree (where each element is, in pixels). Building the layout tree requires knowing fonts, content, container dimensions, and CSS rules for every element. It’s expensive — modern pages can take tens of milliseconds.
The browser is smart about this. It batches DOM mutations and only recomputes the layout tree when it absolutely has to — typically before painting. This batching is what makes complex JavaScript UIs feel fast.
The problem is that some JavaScript operations force the browser to abandon that batching and compute the layout immediately, synchronously, in the middle of your code. This forced synchronous computation is layout reflow.
What triggers reflow?
Reading any geometry property when the layout is “dirty” (meaning JavaScript has changed something since the last layout pass) triggers reflow:
// This write makes the layout dirty
element.style.width = '300px'
// This read forces immediate reflow — the browser must
// finish layout before it can give you a valid answer
const height = element.getBoundingClientRect().height
// Another write, another dirty mark
element.style.fontSize = '18px'
// Another forced reflow
const newHeight = element.offsetHeight
The pattern above — write, read, write, read — is called layout thrashing. Each read-after-write pair forces a full layout recalculation.
The properties that trigger forced reflow include:
getBoundingClientRect()offsetHeight,offsetWidth,offsetTop,offsetLeftscrollHeight,scrollWidthclientHeight,clientWidthgetComputedStyle()
Measuring text is always a dirty read
Here’s the specific pain point for text measurement. When you want to know how tall a block of text will be, the standard approach is:
function getTextHeight(text, font, maxWidth) {
const el = document.createElement('div')
el.style.cssText = `
position: fixed;
top: -9999px;
width: ${maxWidth}px;
font: ${font};
word-break: break-word;
`
el.textContent = text
document.body.appendChild(el)
const height = el.getBoundingClientRect().height // ← forced reflow
document.body.removeChild(el)
return height
}
Every call to getTextHeight() appends a DOM element (dirties the layout), reads geometry (forces reflow), and removes the element (dirties again). If you call this for 100 list items, you trigger 100 synchronous layout passes.
On a complex page, each forced reflow can take 10–50 ms. For 100 items that’s potentially 5 seconds of blocked main thread — for measurements only.
When this actually hurts
Most websites never run into this. A static blog with 10 posts does not care. The patterns where it becomes critical are:
AI streaming chat
A streaming response updates a chat bubble every few milliseconds. If you compute height using DOM measurement, each token triggers a reflow. At even 10 tokens per second, you’re forcing 10 layout passes per second just for height, on top of all the regular rendering. Bubbles stutter, animations jank, the scroll anchor fights with each update.
Virtualized lists
Libraries like react-virtual or tanstack/virtual need to know each item’s height before it renders to correctly position items below it. The typical solution is to render items into a hidden container, measure them, then remove them. For a list of 1,000 items this means 1,000 forced reflows before you can even scroll.
Masonry layouts
Masonry requires knowing card heights before placing them in columns. The same hidden-render-then-measure pattern causes the same reflow storm.
Real-time collaboration
Any interface where text content changes rapidly from external updates (collaborative docs, live feeds, dashboards) hits this wall. Content arrives, layout adjusts, measurements update — all fighting each other on the main thread.
Why getBoundingClientRect can’t be made fast
You might wonder: can browsers optimize this? The answer is mostly no. The reason getBoundingClientRect() must trigger a synchronous layout recalculation is that JavaScript is single-threaded and browsers must give you a valid, current answer.
If the browser computed layout lazily in the background (which it does for painting), a JavaScript call that reads geometry would get stale data. Since JavaScript can make decisions based on those measurements (if height > 300 do X), browsers cannot lie about the current layout state.
The only way to make text measurement truly fast is to move it outside the DOM entirely.
That is exactly what pretext does. Instead of asking the browser’s layout engine for heights, it asks the browser’s font engine — through Canvas — which has no layout tree and never causes reflow.
How pretext works: two phases, one canvas, zero reflow
The two-phase mental model — a Canvas font oracle in prepare(), pure arithmetic in layout() — and the full API.
Read on its own: How pretext works: two phases, one canvas, zero reflowThe mental model
Pretext is built around one insight: measuring text and laying out text are two different problems.
Measuring is expensive because it requires the browser’s full font shaping pipeline — kerning, ligatures, complex script rules, emoji clusters. But you only need to do this once per text+font combination.
Laying out is fast if you already have the measurements: it’s just arithmetic — accumulate widths until you overflow the container, insert a line break, repeat.
Pretext separates these cleanly:
prepare(text, font) → PreparedText // Font oracle: measure once
layout(prepared, width, lineHeight) // Pure arithmetic: run many times
Phase 1: prepare() — the canvas oracle
import { prepare } from '@chenglou/pretext'
const prepared = prepare(
'Hello, world! Pretext handles text layout in pure JavaScript.',
'16px Inter',
)
During prepare(), pretext does three things:
1. Segmentation — Using Intl.Segmenter, it splits the text into language-aware segments. This handles CJK characters, Arabic right-to-left text, emoji clusters, and word boundaries correctly across scripts.
2. Canvas measurement — Each segment is measured using CanvasRenderingContext2D.measureText(). This is the key operation. Canvas’s measureText() asks the browser’s font engine for pixel-accurate widths — the same engine that the DOM uses for layout — but without going through the layout pipeline. No reflow, no layout tree, just glyph metrics.
3. Caching — The results are stored in an opaque PreparedText handle. This handle is width-independent: you can call layout() with any container width and the measurements remain valid.
The preparation cost is roughly equivalent to one DOM measurement pass. You pay it once.
Phase 2: layout() — pure arithmetic
import { layout } from '@chenglou/pretext'
const { height, lineCount } = layout(prepared, 320, 24)
// 320px container width, 24px line height
layout() iterates through the cached segment widths, accumulating the running line width. When the accumulated width would exceed maxWidth, it breaks the line. Each line break adds lineHeight to the total height.
This is integer/float arithmetic on an array of numbers. No DOM access, no Canvas calls, no font engine interaction. It runs in microseconds regardless of text length.
The resize case — the most important optimization — becomes trivial:
const prepared = prepare(text, font) // once
// Respond to container resize — just arithmetic
window.addEventListener('resize', () => {
const { height } = layout(prepared, container.offsetWidth, 24)
element.style.height = `${height}px`
})
The rich API: prepareWithSegments + layoutWithLines
The fast path gives you height and line count. When you need actual line content — for custom rendering, cursor placement, or streaming layout — use the rich API:
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext'
const prepared = prepareWithSegments(
'Every token that streams adds to the layout cost — unless you use pretext.',
'18px "Helvetica Neue"',
)
const { lines } = layoutWithLines(prepared, 320, 26)
for (const line of lines) {
console.log(`"${line.text}" — ${line.width}px`)
}
Each LayoutLine carries text, width, start, and end cursors. The cursors are segment/grapheme positions — not raw string offsets — so they stay correct across multi-byte characters and emoji.
Streaming layout: layoutNextLine
For AI streaming, you often need to flow text line by line as tokens arrive. The layoutNextLine function handles this:
import {
prepareWithSegments,
layoutNextLine,
type LayoutCursor,
} from '@chenglou/pretext'
const prepared = prepareWithSegments(streamingText, '16px Inter')
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }
// Called each time text grows
function renderNextLine(containerWidth: number) {
const line = layoutNextLine(prepared, cursor, containerWidth)
if (!line) return
drawLine(line.text, currentY)
cursor = line.end
currentY += lineHeight
}
This is the API that makes streaming AI chat smooth: each new token may extend the current line or start a new one, and the cost of that determination is nanoseconds.
What pretext handles (and what it doesn’t)
Handles correctly:
- Latin, CJK (Chinese, Japanese, Korean), Arabic, Hebrew, Thai, Khmer
- Emoji and emoji sequences (🏳️🌈 counts as one cluster)
- Bidirectional text (Unicode BiDi algorithm)
overflow-wrap: break-wordfor very long wordswhite-space: pre-wrap(tabs, newlines, explicit spaces)word-break: keep-allfor CJK headlines
Does not handle:
system-uifont on macOS — Canvas and DOM can resolve different optical variants for the same size. Always use explicit font names:'16px Inter'not'16px system-ui'- Server-side rendering — Canvas requires a browser environment. The
prepare()call must run client-side - Full CSS inline formatting (nested spans with mixed fonts) — use the inline-flow sidecar for that
When not to use it
Pretext adds setup overhead (prepare() is as expensive as one DOM pass) and mental complexity. It is the wrong tool for:
- Static content that does not resize or stream
- One-time measurements of a handful of elements
- Any context where you’re not in the “measure many times” hot path
If getBoundingClientRect() is not causing you performance problems, pretext will not help. Add complexity when the numbers justify it.
The theory is clean. The demos that follow make it tangible.
React demo: streaming chat with zero reflow
A live streaming AI chat where every bubble height is computed with pretext — no DOM reads, no reflow, smooth expansion.
Read on its own: React demo: streaming chat with zero reflowThe demo
Hit Simulate stream to watch AI messages arrive token by token. Each bubble expands smoothly as text fills in — no jank, no reflow, no layout thrashing. Then switch to DOM mode and run it again to see the difference in the fps counter.
This demo runs in your browser and needs JavaScript. The text around it explains what it shows.
This demo runs in your browser and needs JavaScript. The text around it explains what it shows.
What’s happening
In pretext mode, every frame does this:
- Get the current streamed text slice
- Call
prepare(text, font)— canvas measurement, runs once when text changes - Call
layout(prepared, bubbleWidth, lineHeight)— pure arithmetic, returns height - Set
style.heighton the bubble
No DOM reads after step 2. The browser’s layout tree is never dirtied by the measurement itself.
In DOM mode, every frame does this instead:
- Get the current streamed text slice
- Create a hidden
<div>, set its text and styles, append todocument.body - Call
getBoundingClientRect()— forces synchronous layout reflow - Remove the hidden element
- Set
style.heighton the bubble
The forced reflow in step 3 blocks the main thread. With multiple messages streaming simultaneously, the reflows compound.
The core pattern
import { prepare, layout } from '@chenglou/pretext'
function measureBubbleHeight(text: string, maxWidth: number): number {
const prepared = prepare(text, '14px ui-sans-serif, system-ui, sans-serif')
const { height } = layout(prepared, maxWidth, 22)
return height + 24 // add padding
}
// Inside your streaming update handler:
const height = measureBubbleHeight(currentText, bubbleWidth)
element.style.height = `${height}px`
In production you’d cache the PreparedText handle across streaming ticks — only call prepare() when the full text changes significantly, not on every token. The layout() call is the cheap one; it’s the one you call per frame.
Handling multiple simultaneous streams
Where the gains compound is when multiple messages are streaming at once. Pretext makes each one independent:
// All of these run in the same animation frame with no reflow
for (const message of streamingMessages) {
const { height } = layout(message.prepared, containerWidth, LINE_HEIGHT)
message.element.style.height = `${height}px`
}
With DOM measurement, each element access would force a reflow that potentially invalidates the measurements you just computed for the previous elements. With pretext, all measurements are independent arithmetic operations on cached data.
The pre-wrap case
Textarea-like inputs need white-space: pre-wrap to respect hard newlines and tabs:
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare(textareaValue, '14px ui-monospace', {
whiteSpace: 'pre-wrap',
})
const { height } = layout(prepared, textareaWidth, 20)
This is useful for auto-growing textareas — one of the most common patterns that traditionally required DOM measurement loops.
Matteflow: text that flows around a dancer
A canvas demo — extract a foreground silhouette with a color-distance matte, then flow article text around it every frame with pretext.
Read on its own: Matteflow: text that flows around a dancerThe demo
Load any video of a person against a plain background. The demo extracts the foreground using a color-distance matte and flows article text around the moving silhouette — every frame, using pretext for line placement.
This demo runs in your browser and needs JavaScript. The text around it explains what it shows.
This demo runs in your browser and needs JavaScript. The text around it explains what it shows.
Best results: videos with a person against a solid or lightly textured background (studio green/white screen, plain wall). The sliders let you tune the wrap strength, subject scale, and position.
How it works
This demo is a compressed port of Matteflow by summerKK — an editorial layout prototype that demonstrates exactly why pretext exists. The original has four modules; here they’re ported into one React component and the small pure modules it draws with.
Step 1: color-distance matte
Each video frame is drawn to an offscreen canvas and the pixel data is read once:
offCtx.drawImage(video, 0, 0, canvasWidth, canvasHeight)
const { data } = offCtx.getImageData(0, 0, canvasWidth, canvasHeight)
The background color is estimated by sampling the frame edges (corners + borders). Then each pixel gets an alpha value based on its color distance from that background:
const distance = Math.sqrt((r - bg.r) ** 2 + (g - bg.g) ** 2 + (b - bg.b) ** 2)
const alpha = clamp((distance - threshold) / feather, 0, 1)
A pixel close to the background color gets alpha ≈ 0 (transparent). A pixel far from it gets alpha ≈ 1 (foreground). The demo fixes both numbers (threshold and feather are constants in the renderer), so its sliders do not touch the matte: Wrap sets how strongly the text bends around the silhouette, Scale sizes the figure, and V-Shift / H-Shift move it inside the frame.
Step 2: wrap profile
The alpha mask is divided into horizontal bands. For each band, the leftmost and rightmost foreground pixels define the subject width and horizontal center at that height:
// For each band (vertical slice of the figure)
const bandWidth = (rightPixel - leftPixel) / frameWidth
const bandCenter = (leftPixel + rightPixel) / 2 / frameWidth - 0.5
profile.push({ width: bandWidth, offset: bandCenter })
This profile — 10 numbers describing the silhouette shape — is computed every frame. It drives where the text exclusion zones are.
Step 3: text layout with pretext
For each text line, the wrap regions are computed from the profile:
const regions = computeWrapRegions({
y,
lineHeight,
stageCenterX,
stageWidth,
profile,
wrapStrength,
gutter,
articleLeft,
articleWidth,
stageTop,
stageHeight,
})
computeWrapRegions returns one or two column regions — the areas to the left and right of the subject that are wide enough for text. Then layoutNextLine from pretext places text into each region:
for (const region of regions) {
const line = prepared.nextLine(cursor, region.width)
if (!line) break
lines.push({ text: line.text, x: region.x, y })
cursor = line.end
}
This runs per frame. The expensive part — font measurement — happened once when the component mounted. The per-frame work is arithmetic: profile computation + line break logic. The result is text that genuinely follows the moving shape.
Why this matters beyond the demo
The Matteflow demo is not a real-world feature. But the pattern it demonstrates is:
Text layout as a pure function of content + geometry, evaluated at will.
When layout() costs 0.09 ms, you can call it in your render loop without a second thought. You can rebuild the entire text layout every frame in response to any change — container resize, content update, obstacle movement — and the user never notices the cost.
Without pretext, this demo would require either:
- Pre-computing layouts for every possible video frame (infeasible)
- Running DOM measurement off-thread (not possible; layout is main-thread only)
- Accepting the reflow cost and watching the frame rate drop (the wrong answer)
The library’s value is not just speed. It is that text measurement becomes a value you compute — not a side effect you wait for.
This piece is based on the pretext README and Cheng Lou’s original library. The Matteflow concept is from summerKK/Matteflow.