DEV Community

Cover image for The frame was fast. The relayout was 65 too slow.
Sai Chandan Kadarla
Sai Chandan Kadarla

Posted on Originally published at saichandankadarla.com

The frame was fast. The relayout was 65 too slow.

I ported js-framework-benchmark's protocol onto Vel's core — no window, no GPU, no vsync, pure CPU — because I wanted to know if "compiles to native C++" actually translated into the layout numbers I was assuming it did. Each row is a color swatch, a flexible text label, and a button. I created 10,000 of them and measured.

The first number was great. The second number was embarrassing.

What the benchmark found

| rows  | build (tree) | layout (cold) | relayout (warm) |
|------:|-------------:|--------------:|----------------:|
|   100 |      0.03 ms |      0.04 ms  |       0.04 ms   |
|  1000 |      0.26 ms |      0.30 ms  |     →19.6 ms←   |
| 10000 |      1.99 ms |      2.22 ms  |     → 206 ms←   |
Enter fullscreen mode Exit fullscreen mode

Build — constructing 10,000 widgets — costs 2ms. This is where the "native C++" thesis pays off bluntly: C++ allocation crushes JavaScript's createElement. Nothing to do here; it was already faster than the framework I was benchmarking against.

Now look at the last column. Relayout (warm) is the cost of laying out a tree that did not change — same widgets, same text, same constraints, the steady state you're in every frame while the user just moves the mouse. It cost 206ms for 10k rows. That's not a 60fps frame; that's three frames dropped to lay out a list that didn't move.

And the tell is in the comparison: warm relayout cost the same as the cold pass. Re-laying-out an unchanged tree was doing the full amount of work as laying it out for the first time. That's the signature of zero memoization — every frame, from scratch, as if it had never seen this tree before.

The cost wasn't geometry — it was text

My assumption was that "layout is slow" meant the flexbox math was slow: constraint propagation, two passes, intrinsic sizing. I was wrong, and a profiler said so immediately. The 206ms was almost entirely in one place:

Text::measureFreeTypeRasterizer::measureText, which walks each string codepoint by codepoint and asks FreeType for the advance width of every glyph via FT_Load_Glyph.

Per frame. For every label. Whether or not the text had changed.

FT_Load_Glyph is not free — it's loading and scaling a glyph outline to get its metrics. Doing it for every character of every visible string, 60 times a second, on text that is identical to last frame, is pure waste. The geometry math was a rounding error next to it. I'd been optimizing the wrong mental model of where layout time goes.

Two caches, both valid forever

The fix is in engine/src/text/FreeTypeRasterizer.cpp — two process-lifetime caches:

  • Per-glyph advance cache, keyed on (face, pixelSize, codepoint). A glyph's advance width never changes for a given face and size, so the first time you measure an 'e' at 32px you load it; every 'e' after that is a hashmap hit. This makes even cold layout of varied text fast, because common characters are shared across every string.
  • Per-string width cache, keyed on (face, pixelSize, string). Relayout of unchanged text becomes a single lookup — measure the label once, and every subsequent frame that the text is identical is O(1). It's bounded to 200k entries so that live typing (which generates a new string every keystroke) can't grow it without limit.

The key insight that makes this safe: these values are immutable. A glyph advance for a fixed face and pixel size is a constant of the universe; it will never be different. So there's no invalidation logic, no staleness, no cache-coherence problem — the hard part of caching simply doesn't exist here. You compute it once and trust it forever.

The result:

rows relayout before relayout after speedup
1000 19.6 ms 0.30 ms ~65×
10000 206 ms 2.2 ms ~93×

A 10,000-row list now relays out in ~2ms — comfortably inside a 60fps frame. That's the budget Figma-class apps live in, and it was the difference between "compiles to native, therefore fast" being a slogan and being true.

The other half: don't lay out at all

Caching makes a frame cheap. The bigger win is not running the frame. Vel is damage-tracked: an atomic frameDirty flag, raised by any Widget::markDirty(), gates whether the next frame does anything. When nothing's changed, the app sits in glfwWaitEventsTimeout and uses ~0 CPU — no layout, no paint, no spin. Animating widgets re-arm the flag from their tick(); a static page just sleeps.

So the steady state is: idle costs nothing, and when something does change, the relayout it triggers is ~2ms instead of 206ms. Both halves matter. A fast frame you run 60 times a second on an idle app is still a battery fire.

What it costs

  • The string cache trades memory for time, bounded crudely. 200k entries is a fixed ceiling, not an LRU — it's a backstop against live-typing churn, not a tuned eviction policy. For pathological workloads (millions of unique strings) you'd want real eviction.
  • It's still a full tree walk. Relayout re-visits every node; it's just that each visit is now cheap. The honest next step is dirty-subtree layout — skipping subtrees whose constraints and content are unchanged — so a 100k-row tree doesn't pay even the cheap per-node cost. Today the whole tree is re-walked; it's fast enough that I haven't needed to, which is its own kind of answer.
  • It assumes advances are independent. The per-string cache works because today a string's width is the sum of its glyphs' advances. HarfBuzz shaping will break that assumption (kerning, ligatures, complex scripts) — the cache key still holds (same string, same width) but the per-glyph cache stops being sufficient on its own.

The meta-lesson is the one I keep relearning: measure before you optimize, because your intuition about where the time goes is usually wrong. I'd have happily spent a week making the flex math faster and moved the 206ms to 204ms. The profiler pointed at text measurement in thirty seconds, and a cache with no invalidation logic — the easy kind — bought two orders of magnitude.

Top comments (0)