DEV Community

Cover image for Pretext.js: The 15KB Library That Makes Text Layout 500x Faster
Preecha
Preecha

Posted on

Pretext.js: The 15KB Library That Makes Text Layout 500x Faster

TL;DR

Pretext.js is a zero-dependency TypeScript library that measures and positions multiline text with arithmetic instead of DOM layout reads. It avoids forced synchronous reflows, reports benchmarks up to ~500x faster than getBoundingClientRect() for text measurement, and supports major writing systems. If you build virtual scrollers, chat UIs, or data grids, it targets one of the most expensive browser UI bottlenecks: measuring dynamic text.

Try Apidog today

Introduction

Every time JavaScript calls getBoundingClientRect() or reads layout properties like offsetHeight, the browser may stop to flush pending styles, recalculate layout, and return the computed value. That forced synchronous reflow is expensive.

Now apply that to:

  • 1,000 chat bubbles in a virtualized list
  • 10,000 rows in a data grid
  • Streaming API responses that constantly append text
  • Multilingual feeds with unpredictable line wrapping

The result is usually dropped frames, visible scroll jumps, and layout jank.

Apidog teams building API-driven frontends know this pain well: streaming response data into dynamic UIs is hard when every layout measurement fights the rendering pipeline.

Cheng Lou, the developer behind react-motion and a core contributor to React and ReasonML at Meta, built Pretext.js to address this. The library shipped in March 2026, gained 14,000+ GitHub stars in days, and triggered a large Hacker News discussion.

This guide focuses on how Pretext.js works, where it fits, how to integrate it, and when not to use it.

What is Pretext.js?

Pretext.js is a pure JavaScript/TypeScript text layout engine. It measures and positions multiline text through arithmetic instead of DOM reads.

That means:

  • No getBoundingClientRect()
  • No offsetHeight
  • No forced reflow during layout calculation
  • No off-screen DOM measurement loop

Image

The core idea: instead of asking the browser, “How tall is this rendered text?”, Pretext.js computes the answer from font metrics and line-breaking rules.

Basic usage:

import { prepare, layout } from '@chenglou/pretext';

// Step 1: prepare text once
const handle = prepare('Hello, pretext.js', '16px "Inter"');

// Step 2: compute layout at a target width
const { height, lineCount } = layout(handle, 400, 24);

console.log({ height, lineCount });
Enter fullscreen mode Exit fullscreen mode

The API has two main functions:

  • prepare(text, font) measures and caches text segments.
  • layout(handle, width, lineHeight) computes wrapping and height.

prepare() touches the browser through Canvas text measurement. After that, layout() is arithmetic.

Why this matters for API-heavy applications

If your app consumes streaming or frequently changing API data, you often need to know text height before rendering.

Common examples:

  • AI chat interfaces streaming tokens
  • Real-time dashboards showing logs or events
  • Collaborative editors
  • Chat and comment feeds
  • Data grids with variable-height cells

Without precomputed heights, virtual scrollers usually fall back to one of these options:

  1. Render first, measure later.
  2. Estimate height, then correct after render.
  3. Force fixed-height rows.

All three have tradeoffs. Pretext.js gives you a way to compute text height before creating DOM nodes.

The problem Pretext.js solves

Forced synchronous reflow

This pattern is common and expensive:

const elements = document.querySelectorAll('.text-block');

elements.forEach((el) => {
  const height = el.getBoundingClientRect().height;
  // use height for positioning...
});
Enter fullscreen mode Exit fullscreen mode

Each layout read can force the browser to:

  • Pause JavaScript
  • Flush pending style changes
  • Recalculate layout
  • Return the computed geometry

In loops, this becomes layout thrashing.

For virtualized UI, that cost is especially painful because the whole point is to avoid rendering and measuring thousands of DOM nodes.

The virtual scrolling problem

Virtual scrollers such as react-window or @tanstack/react-virtual need item heights to compute scroll offsets.

Fixed-height rows are easy:

const rowHeight = 48;
const offset = index * rowHeight;
Enter fullscreen mode Exit fullscreen mode

Variable-height text is harder:

const offset = heights.slice(0, index).reduce((a, b) => a + b, 0);
Enter fullscreen mode Exit fullscreen mode

The challenge is getting heights without rendering every row.

Traditional options include:

  • Hidden off-screen render containers
  • Post-render measurement
  • Estimated heights
  • Fixed-height constraints

Pretext.js replaces those workarounds with a pre-render calculation.

Benchmark numbers

Pretext.js published benchmark results comparing DOM measurement with layout().

Approach 1,000 text blocks 500 text blocks
DOM with getBoundingClientRect() ~94ms ~47ms
Pretext.js layout() ~2ms ~0.09ms
Speed difference ~47x faster ~500x faster

The improvement comes from avoiding layout reads. DOM measurement has browser rendering overhead; Pretext’s layout() step is arithmetic over prepared segment widths.

How Pretext.js works

Pretext.js has three main phases.

1. Text segmentation

When you call prepare(), Pretext.js normalizes and segments the input text.

It accounts for:

  • Whitespace handling
  • Unicode line breaking rules, including UAX #14
  • Breakable units
  • Multilingual text behavior

This matters because line wrapping is not the same across writing systems.

Examples:

  • CJK text can break at character-level boundaries.
  • Arabic and Hebrew require bidirectional text handling.
  • Thai does not use spaces between words in the same way English does.
  • Hindi/Devanagari can include conjunct consonants and ligatures.
  • Emoji can span multiple code points.
  • Soft hyphens introduce conditional break opportunities.

2. Canvas measurement

After segmentation, Pretext.js measures segments with Canvas measureText().

Example of the underlying browser primitive:

const canvas = new OffscreenCanvas(1, 1);
const ctx = canvas.getContext('2d');

if (!ctx) {
  throw new Error('Canvas context unavailable');
}

ctx.font = '16px "Inter"';

const metrics = ctx.measureText('Hello');
const width = metrics.width;
Enter fullscreen mode Exit fullscreen mode

Canvas text measurement does not trigger DOM layout reflow. Pretext.js caches measurements by segment and font combination, so repeated text/font pairs can reuse previous work.

3. Arithmetic layout

layout() takes the prepared handle, a target width, and a line height.

Conceptually, it does this:

let currentLineWidth = 0;
let lineCount = 1;

for (const segment of segments) {
  if (currentLineWidth + segment.width > containerWidth) {
    lineCount++;
    currentLineWidth = segment.width;
  } else {
    currentLineWidth += segment.width;
  }
}

const height = lineCount * lineHeight;
Enter fullscreen mode Exit fullscreen mode

The actual implementation handles Unicode and text layout details, but the performance win comes from the same principle: no DOM, no reflow, no render pass.

Reuse prepared handles

One useful pattern is preparing text once and laying it out at multiple widths.

import { prepare, layout } from '@chenglou/pretext';

const handle = prepare(longArticleText, '16px "Inter"');

const mobile = layout(handle, 375, 24);
const tablet = layout(handle, 768, 24);
const desktop = layout(handle, 1200, 24);

console.log({
  mobileHeight: mobile.height,
  tabletHeight: tablet.height,
  desktopHeight: desktop.height,
});
Enter fullscreen mode Exit fullscreen mode

This is useful for responsive layouts where the same content needs to be measured against different container widths.

Practical implementation patterns

1. Variable-height virtual scrolling

Use Pretext.js to precompute row heights before passing them into a virtualizer.

import { prepare, layout } from '@chenglou/pretext';

interface TextItem {
  id: string;
  content: string;
}

function computeHeights(items: TextItem[], containerWidth: number) {
  const font = '14px "Inter"';
  const lineHeight = 20;
  const verticalPadding = 32;

  return items.map((item) => {
    const handle = prepare(item.content, font);
    const { height } = layout(handle, containerWidth, lineHeight);

    return {
      id: item.id,
      height: height + verticalPadding,
    };
  });
}

const heights = computeHeights(chatMessages, 600);
Enter fullscreen mode Exit fullscreen mode

This avoids:

  • Rendering hidden rows
  • Measuring DOM nodes
  • Correcting estimated heights after paint

2. AI chat interfaces

Streaming chat UIs update text many times while a response is being generated. If every update causes DOM measurement, the UI can stutter.

With Pretext.js, update the item height from text data:

import { prepare, layout } from '@chenglou/pretext';

let streamedText = '';

const font = '15px "SF Pro"';
const lineHeight = 22;
const bubbleWidth = 520;
const padding = 24;

socket.on('token', (token: string) => {
  streamedText += token;

  const handle = prepare(streamedText, font);
  const { height } = layout(handle, bubbleWidth, lineHeight);

  scroller.updateItemHeight(messageId, height + padding);
});
Enter fullscreen mode Exit fullscreen mode

For better performance, batch token updates instead of recalculating on every single character:

let pending = '';
let scheduled = false;

socket.on('token', (token: string) => {
  pending += token;

  if (!scheduled) {
    scheduled = true;

    requestAnimationFrame(() => {
      streamedText += pending;
      pending = '';
      scheduled = false;

      const handle = prepare(streamedText, font);
      const { height } = layout(handle, bubbleWidth, lineHeight);

      scroller.updateItemHeight(messageId, height + padding);
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

3. Data grids with text-heavy cells

Data grids often need row heights before rendering visible rows.

import { prepare, layout } from '@chenglou/pretext';

interface GridRow {
  id: string;
  description: string;
}

function computeRowHeight(row: GridRow, columnWidth: number) {
  const font = '13px system-ui';
  const lineHeight = 18;
  const verticalPadding = 16;

  const handle = prepare(row.description, font);
  const { height } = layout(handle, columnWidth, lineHeight);

  return height + verticalPadding;
}
Enter fullscreen mode Exit fullscreen mode

Use this when rows contain long descriptions, logs, comments, or other variable-length text.

4. Multilingual feeds

Mixed-script feeds are difficult to virtualize because line-breaking behavior varies across languages.

import { prepare, layout } from '@chenglou/pretext';

const posts = [
  { text: 'This library changed everything', lang: 'en' },
  { text: 'RTL text with correct bidirectional layout', lang: 'ar' },
  { text: 'CJK text gets proper character-level breaks', lang: 'zh' },
];

const font = '16px system-ui';
const width = 400;
const lineHeight = 24;

for (const post of posts) {
  const handle = prepare(post.text, font);
  const { height, lineCount } = layout(handle, width, lineHeight);

  console.log(post.lang, { height, lineCount });
}
Enter fullscreen mode Exit fullscreen mode

The same API handles different writing systems.

Testing text-heavy API flows with Apidog

When a UI depends on API-provided text, layout correctness also depends on response quality.

Image

Use Apidog to test the API payloads that feed your text components. For example, you can create scenarios for:

  • Short, medium, and long text responses
  • Streaming chunks that simulate LLM output
  • Multilingual payloads
  • Emoji and Unicode edge cases
  • Empty or missing text fields
  • Unexpectedly large responses

For AI chat products, this helps validate:

  • Chunked streaming behavior
  • Response schema correctness
  • Text field formats
  • Edge cases that affect rendering
  • Automated regression coverage for text layout bugs

Fast measurement helps, but bad API data can still produce broken UI. Test both the rendering layer and the API responses that drive it.

Known limitations

Pretext.js is not a replacement for the browser layout engine in every case.

Rendering accuracy edge cases

Because Pretext.js computes layout separately from DOM rendering, it can diverge from the browser in some cases.

Potential causes include:

  • Unusual kerning pairs
  • Mixed font sizes inside one block
  • Subpixel rendering differences
  • Browser-specific text shaping behavior
  • Differences between Canvas measurement and DOM text rendering

For virtual scrolling, small differences may be acceptable. For pixel-perfect typography, DOM measurement remains the ground truth.

prepare() still has a cost

layout() is fast, but prepare() still uses Canvas measurement.

Avoid calling prepare() repeatedly for unchanged text. Cache handles by text and font:

import { prepare, layout } from '@chenglou/pretext';

const cache = new Map<string, ReturnType<typeof prepare>>();

function getPreparedText(text: string, font: string) {
  const key = `${font}::${text}`;

  let handle = cache.get(key);

  if (!handle) {
    handle = prepare(text, font);
    cache.set(key, handle);
  }

  return handle;
}

function measureTextHeight(text: string, font: string, width: number, lineHeight: number) {
  const handle = getPreparedText(text, font);
  return layout(handle, width, lineHeight).height;
}
Enter fullscreen mode Exit fullscreen mode

Limited CSS property support

Pretext.js measures raw text with a font specification. It does not fully account for CSS properties such as:

  • letter-spacing
  • word-spacing
  • text-indent
  • text-transform
  • font-feature-settings
  • font-variant

If your rendered text depends heavily on these properties, measured height may not match the DOM.

It does not render text

Pretext.js computes text layout metrics. It does not display text.

You still render with:

  • DOM
  • Canvas
  • SVG
  • WebGL
  • Another rendering target

Its value is in the measurement step before rendering.

Pretext.js vs. traditional approaches

Feature Pretext.js DOM measurement Estimated heights
Speed for 1K items ~2ms ~94ms ~0ms
Accuracy High, Canvas-based Ground truth Heuristic
DOM dependency None after prepare() Full None
Reflow triggers Zero during layout() One per measurement Zero
Multilingual support Unicode-aware Browser-native Usually poor
CSS property support Limited Full None
Memory overhead Cached segments DOM nodes Minimal
Responsive layout One prepare(), many layout() calls Re-measure per width Re-estimate per width

Use DOM measurement when you need maximum fidelity. Use Pretext.js when you need fast pre-render measurement for many text blocks and can tolerate minor differences.

Getting started

Installation

npm install @chenglou/pretext
# or
pnpm add @chenglou/pretext
# or
bun add @chenglou/pretext
Enter fullscreen mode Exit fullscreen mode

Basic usage

import { prepare, layout } from '@chenglou/pretext';

const handle = prepare(
  'Pretext.js computes text layout without touching the DOM.',
  '16px "Inter"'
);

const result = layout(handle, 600, 24);

console.log(result.height);
console.log(result.lineCount);
Enter fullscreen mode Exit fullscreen mode

React integration with TanStack Virtual

import { prepare, layout } from '@chenglou/pretext';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useMemo, useRef } from 'react';

function VirtualChat({ messages }: { messages: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const containerWidth = 600;
  const font = '14px "Inter"';
  const lineHeight = 20;
  const verticalPadding = 24;

  const heights = useMemo(() => {
    return messages.map((message) => {
      const handle = prepare(message, font);
      const { height } = layout(handle, containerWidth, lineHeight);

      return height + verticalPadding;
    });
  }, [messages]);

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: (index) => heights[index],
  });

  return (
    <div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
      <div
        style={{
          height: virtualizer.getTotalSize(),
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: virtualRow.start,
              width: containerWidth,
            }}
          >
            {messages[virtualRow.index]}
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This computes item heights before messages are rendered by the virtual list.

Wait for web fonts before measuring

If your app uses web fonts, make sure they are loaded before calling prepare().

async function measureAfterFontsLoad(text: string) {
  await document.fonts.ready;

  const handle = prepare(text, '16px "Inter"');
  return layout(handle, 600, 24);
}
Enter fullscreen mode Exit fullscreen mode

If the font is not loaded, Canvas may measure with a fallback font and produce incorrect results.

Interactive playground

The Pretext.js website includes an interactive playground at pretextjs.dev/playground, where you can paste text, choose fonts, adjust container width, and inspect layout behavior before integrating it.

When not to use Pretext.js

Avoid Pretext.js when the browser already solves your problem cheaply.

Good reasons not to use it:

  • Your page is static and not virtualized.
  • You only have a small list of items.
  • You need pixel-perfect print layout.
  • Your text depends heavily on CSS text properties.
  • You need server-side measurement without Canvas polyfills.
  • DOM measurement cost is already negligible in your UI.

For example, measuring 50 static elements may be fast enough with the DOM. The extra dependency may not be worth it.

FAQ

Is Pretext.js production-ready?

The library shipped in March 2026 and gained 14,000+ GitHub stars within days. Its creator, Cheng Lou, runs Midjourney’s frontend, and the test suite covers many languages and edge cases.

That said, it is still a new release. Pin your dependency version and test it with your actual fonts, content, browsers, and layout constraints.

Does it work with React, Vue, and Svelte?

Yes. Pretext.js is framework-agnostic.

You can call prepare() and layout() from:

  • React hooks
  • Vue composables
  • Svelte stores
  • Plain JavaScript modules
  • Worker-like architecture where browser APIs are available

How does it handle web fonts?

prepare() measures with the font available at call time. If the web font has not loaded, measurement may use a fallback font.

Use the Font Loading API:

await document.fonts.ready;
Enter fullscreen mode Exit fullscreen mode

Then call prepare().

Can it be used for Canvas or SVG rendering?

Yes. The computed layout data can inform rendering in DOM, Canvas, SVG, WebGL, or other targets.

Pretext.js handles measurement and layout calculation, not rendering.

Does it support RTL languages?

Yes. Pretext.js supports Arabic, Hebrew, and mixed-direction text with bidirectional handling.

What is the bundle size?

The article reports a 15KB minified bundle with zero dependencies. It uses standard browser APIs such as Canvas measureText() and Intl.Segmenter where available.

How accurate is it compared to DOM measurement?

For most text content, the article reports that Pretext.js matches DOM layout within roughly 1–2 pixels. Accuracy depends on fonts, browser behavior, and CSS properties.

If you use unsupported CSS properties like letter-spacing or word-spacing, expect larger differences.

Can it measure styled text?

Each prepare() call accepts a single font specification. For mixed styles, such as bold words inside regular text, you need to split the text into style runs and measure them separately.

Conclusion

Pretext.js gives developers a practical way to measure multiline text without forcing DOM reflow. For virtual scrollers, chat interfaces, data grids, and streaming text UIs, that can remove a major source of layout jank.

The tradeoff is fidelity. It does not fully support every CSS text property, can differ from DOM rendering in edge cases, and still requires Canvas measurement during prepare().

Use it when you need fast pre-render text heights across many dynamic items. Keep DOM measurement when you need browser-perfect layout accuracy.

Top comments (0)