If you've ever built a chat UI with virtualization, you've hit the same wall: VariableSizeList needs item heights before items render. The browser knows the answer, but asking it costs a layout reflow. This post compares two approaches that both give you accurate heights, and what separates them in practice.
The Problem
Virtualizers skip rendering off-screen items to keep large lists fast. The tradeoff, you have to tell them how tall each item is upfront. For chat bubbles where height depends on text wrapping, font metrics, and container width; that means either measuring in the DOM or calculating without it.
CellMeasurer (react-virtualized) renders each row and reads its real height via getBoundingClientRect. Pretext calculates the same answer using Canvas, without touching the DOM.
Both give accurate heights. The difference is when and how the work happens.
The Setup
Two branches, identical CSS and message data — 200 messages, mix of short and long text, including CJK and emoji.
| Branch | Method | Library |
|---|---|---|
| CellMeasurer |
getBoundingClientRect per row |
react-virtualized |
| Pretext | Canvas via font engine | react-window + @chenglou/pretext |
How Each Approach Works
CellMeasurer (react-virtualized)
const cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: 60 });
const rowRenderer = ({ index, key, parent, style }) => (
<CellMeasurer cache={cache} parent={parent} rowIndex={index} columnIndex={0}>
{({ measure, registerChild }) => (
<div ref={registerChild} style={style}>
<div className="content" onLoad={measure}>
<p>{messages[index].message}</p>
</div>
</div>
)}
</CellMeasurer>
);
Renders each row into the DOM, reads its real height via getBoundingClientRect, caches it. Accurate on first render of each row. Triggers reflow once per message per container width.
Pretext (react-window)
import { prepare, layout } from "@chenglou/pretext";
const prepareCache = useRef(new Map());
const preparedMessages = useMemo(() => {
return messages.map((msg, i) => {
const key = msg._id ?? i;
if (!prepareCache.current.has(key)) {
prepareCache.current.set(key, prepare(String(msg.message), FONT));
}
return { ...msg, prepared: prepareCache.current.get(key) };
});
}, [messages]);
const getItemSize = useCallback((index) => {
const msg = preparedMessages[index];
if (!msg || containerWidth === 0) return 60;
const { height } = layout(msg.prepared, containerWidth * 0.5, LINE_HEIGHT);
return height + BUBBLE_PADDING_VERTICAL + VERTICAL_GAP;
}, [preparedMessages, containerWidth]);
prepare() segments the text and measures it via Canvas once. layout() is pure arithmetic. No DOM, no reflow, called freely on every resize.
Performance Results
Recorded in Chrome DevTools Performance tab, CPU 4x throttle, scrolling through 200 messages.
Rendering time during scroll
| Branch | Layout (ms) | Recalculate Style (ms) | Layout Forced events |
|---|---|---|---|
| CellMeasurer | 7.1ms | 6.2ms | ~10 per viewport |
| Pretext | 3.6ms | 0.4ms | 0 |
CellMeasurer
Pretext
An important nuance: VariableSizeList only renders ~10 items at a time. CellMeasurer measures lazily. Only rows entering the viewport get a reflow. So on initial load it's ~10 reflows, not 200. As you scroll through unseen messages, each new row triggers one reflow on first appearance, then gets cached. Over a full scroll of 200 messages you accumulate ~200 reflows total, but spread across the scroll session, not all at once.
Pretext has zero reflows at any point. Initial load, mid-scroll, or on rows seen for the first time.
Scripting time
| Branch | Scripting (ms per 10 rows) |
|---|---|
| CellMeasurer | ~5ms (cached: ~0.1ms) |
Pretext layout()
|
~1.5ms total |
Pretext prepare()
|
~8ms (one-time, amortized per message) |
Pretext's layout() is pure arithmetic and cheap. prepare() is the one-time Canvas measurement pass, run it when the message arrives, not during render. CellMeasurer is fast after the first pass but the reflow cost per new row is paid continuously as you scroll.
Height accuracy
Tested with 20 messages containing mixed ASCII, CJK characters, and emoji.
| Branch | Clipped bubbles | Over-sized bubbles |
|---|---|---|
| CellMeasurer | 0 | 0 |
| Pretext | 0 | 0 |
Both are accurate. This is not what separates them.
Resize behaviour
Resize the container window while messages are loaded.
| Branch | On resize |
|---|---|
| CellMeasurer | Serves stale cached heights after a resize, the cache persists via useRef and isn't invalidated |
| Pretext | Re-runs layout() arithmetic on all items, no reflow |
CellMeasurer
Pretext
The same lazy pattern applies on resize. CellMeasurer doesn't re-measure everything at once, only rows that scroll into view after the cache is cleared. But this means reflows continue throughout the scroll session after every resize, not just once. Pretext calls layout() with the new width for all items upfront. Pure math, no DOM involvement and all done in a single pass.
When Each Approach Makes Sense
Use CellMeasurer if you're already on react-virtualized, don't want to own any measurement logic, and your users are unlikely to resize the window mid-session. The reflow cost is bounded to once per message on load and for a modest message history it's acceptable.
Use Pretext if initial load performance matters, your chat handles non-Latin text, or your layout is responsive and users resize frequently. It's the only option that gives you accurate heights with zero reflow at any point in the session.
Bottom Line
CellMeasurer and Pretext both solve the same problem accurately. Because virtualization only renders ~10 items at a time, CellMeasurer's reflow cost is lower than it first appears. ~10 reflows on initial load, then one per new row as it scrolls into view. It's not a one-time upfront cost, it's a continuous per-row cost across the scroll session.
- CellMeasurer — reflows are lazy and bounded per row, cache makes revisited rows free, but reflows resume after every resize. No measurement logic to own.
-
Pretext — zero reflows at any point.
prepare()runs once per message when it arrives,layout()is arithmetic. You own the call sites.




Top comments (0)