DEV Community

Ikhlak
Ikhlak

Posted on

Measuring Chat Bubbles: Pretext vs. CellMeasurer

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>
);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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)