Skip to content
7 min read

You've Scrolled Through Thousands of Messages Today. Ever Wonder Why It Didn't Lag?

My social media has been filled with Pretext lately. But instead of jumping straight into the API, I wanted to understand what problem it actually solves. At its core, Pretext calculates text height without the usual DOM measurement. Its prepare() step uses the browser’s Canvas font engine to measure text segments once, then its layout() step computes how text wraps and how tall it will be through pure arithmetic, no DOM reads, no reflow.

One place this matters is virtualization, a technique where you only render what’s visible on screen. To virtualize a list with variable-height items, you need to know each item’s height before it exists in the DOM. That’s historically been a hard problem, and it’s exactly what Pretext helps with.

That connection pulled me in. I wanted to understand virtualization from the ground up.

Start With the Symptom

Open any chat app. Scroll through a long conversation, maybe 500 messages. On most well-built apps, it feels smooth. Now imagine a naive implementation where every single message is a real DOM node, all 500 of them mounted at once. With rich content, nested elements, and event listeners on each one, things start to slow down. Sluggish scrolling, input lag, mounting delays.

The question is: why?

The Cause

Every DOM node costs something. The browser has to build a render tree, calculate layout, paint pixels, and composite layers. The more nodes, the more work at each stage.

But here’s the thing, most of those 500 messages aren’t even visible. You can only see maybe 10-15 at a time. The other 485 are sitting off-screen, invisible, doing nothing useful, but still consuming memory.

The Root Cause (And the Browser’s Attempt to Fix It)

The DOM doesn’t distinguish between “visible to the user” and “exists in the document.” If a node is mounted, the browser treats it as real, regardless of whether anyone can see it.

So the real problem is: we’re paying the cost of rendering things nobody is looking at.

Modern browsers have tried to address this. Since 2020, CSS offers content-visibility: auto, which tells the browser to skip rendering work for off-screen elements. It’s a one-line fix and it helps. But the nodes still exist in the DOM. They still consume memory. And for variable-height content, you need to provide an estimated size via contain-intrinsic-size, which often causes scroll jumps when the estimate doesn’t match reality.

For a list of a few hundred simple items, content-visibility: auto might be all you need. But for thousands of items with complex, variable-height content, you need a different approach entirely.

The Solution: Virtualization

The idea is simple. Only render what’s visible. If the user can see 15 items, only process and display those 15. As they scroll, navigate, or move through the content, swap out what’s leaving the viewport and bring in what’s entering it.

This isn’t a web-specific trick. It’s a universal principle. Open-world games like Minecraft only load chunks near the player’s camera. Google Maps renders tiles around your viewport and discards them as you pan away. Mobile apps on iOS (UICollectionView) and Android (RecyclerView) recycle off-screen cells instead of creating new ones. VS Code’s editor virtualizes lines of code, so a 50,000-line file doesn’t mean 50,000 rendered elements. The principle is always the same: don’t spend resources on things the user can’t see.

On the web, this means keeping the DOM small. Mount only the visible items, fake the total scroll height so the scrollbar looks correct, and swap nodes in and out as the user scrolls.

To make this work, you need two things:

  1. A container with a fake total height. This makes the scrollbar behave as if all 500 items are rendered. The user sees a normal scrollbar that reflects the full list length.

  2. A way to know each item’s height before rendering it. The virtualizer needs to calculate which items fall within the visible window. For that, it needs heights.

Here’s a simplified version of the scroll logic:

function handleScroll(e) {
  const scrollTop = e.target.scrollTop;
  const viewportHeight = 600;

  let accumulated = 0;
  const visible = [];

  for (const msg of messages) {
    const itemHeight = heights[msg.id]; // need this BEFORE rendering

    if (
      accumulated + itemHeight > scrollTop &&
      accumulated < scrollTop + viewportHeight
    ) {
      visible.push({ msg, top: accumulated });
    }

    accumulated += itemHeight;
  }

  setVisibleMessages(visible);
  setTotalHeight(accumulated);
}

Notice the critical line: heights[msg.id]. You need each item’s height before it exists in the DOM. And that’s the chicken-and-egg problem of virtualization.

Solving the Height Problem

Before Pretext, there were a few ways to deal with this. All of them involved compromises.

  • Fixed heights. Force every item to be the same height. Simple, but it limits your design. Real content like chat messages, feed posts, or comments rarely has uniform height.
  • Estimate, then correct. Give the virtualizer a guessed height, render the item, measure the real height via getBoundingClientRect(), then update. This is what libraries like react-virtuoso and TanStack Virtual do today, and it works well for most use cases. Scroll jumps are typically minor and quickly corrected. But for content with highly variable heights, the mismatches add up.
  • Off-screen pre-rendering. Render items in a hidden container, measure them, then unmount. Accurate, but slow for large lists, and it defeats part of the purpose of virtualization.
  • Manual Canvas.measureText(). Measure text width using the Canvas API and calculate line wrapping yourself. Accurate for simple text, but fragile with multilingual content, emoji, and complex line-breaking rules.

Now, Pretext exists. It takes that last approach and packages it properly: text segmentation, bidirectional text support, caching, and accurate line-breaking rules. What used to be fragile and DIY is now reliable out of the box.

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

const prepared = prepare(msg.text, "16px Inter");
const { height } = layout(prepared, containerWidth, 24);

prepare() does the heavy work once: segmenting text, measuring character widths via the Canvas font engine, and caching results. layout() then computes how that text wraps at a given width and returns the height. For a batch of 500 texts, layout() takes about 0.09ms total, roughly 0.0002ms per text. Pure math, no reflow.

Pretext’s arithmetic covers text dimensions, but it doesn’t account for CSS padding, margins, images, embeds, or other non-text content. Real-world items like chat messages often contain code blocks, images, or videos whose heights need separate calculation. You’d need to handle those manually and add them to Pretext’s text height. Height measurement is also just one of Pretext’s use cases. It also handles manual line layout for Canvas, SVG, and WebGL rendering. But in the context of virtualization, this is the part that unlocks accurate height pre-calculation without any of the old compromises.

Real World Use Cases

Think about any messaging app. Slack, Discord, WhatsApp Web. Conversations can have thousands of messages with variable-length text, code blocks, and emoji. Without virtualization, scrolling through a long channel would grind to a halt. With it, only the visible messages exist in the DOM. The same principle applies to spreadsheet apps like Google Sheets rendering thousands of rows, or any data-heavy interface where smooth scrolling matters.

In practice, you’d use a library like TanStack Virtual, react-window, or react-virtuoso rather than building this from scratch. These handle the scroll math, buffering, and edge cases for you.

Trade-offs Worth Knowing

Virtualization isn’t free. It breaks Ctrl+F browser search, since unmounted items can’t be found. It complicates screen reader navigation and keyboard focus management. It also makes scroll position restoration harder, for example when navigating away and back, or when new items are inserted above the viewport. Libraries like react-virtuoso handle some of these, but they’re trade-offs to be aware of. And if your list is under a few hundred simple items, you probably don’t need it. content-visibility: auto or just rendering everything might be fine. Virtualization adds real complexity (dynamic resizing, scroll anchoring, rapid-scroll edge cases), so reach for it when you actually see a performance problem, not before.

Combining With Infinite Scroll

Virtualization pairs naturally with infinite scroll. Infinite scroll decides when to fetch more data (as you near the bottom, load the next page). Virtualization decides what to render from everything you’ve fetched.

Without virtualization, infinite scroll actually makes performance worse over time. Every new page adds more DOM nodes that never get cleaned up. Combine them, and you get the best of both: data loads on demand, but only visible items are ever in the DOM.

Scrolled through 500 posts:
├── Fetched from server: 500 posts (in memory)
├── In the DOM: ~15 posts (visible + buffer)
└── Not rendered: ~485 posts (data only, ready when needed)

Closing Thought

I followed a trending library, and it led me to a concept I wish I’d learned earlier. Understanding the layers, from DOM cost, to browser optimizations like content-visibility, to full virtualization, to the height problem and how tools like Pretext solve it, gives you the judgment to pick the right approach for the scale you’re actually dealing with. Not every list needs virtualization. But when it does, knowing why it works is what levels you up.

AI was involved in writing this blog. If anything seems off, feel free to let me know.

© 2026 All rights reserved.