#9431·lynx

[Bug]: Custom `@font-face` cold-start cost scales with number of text nodes/rules (up to +2s), inside a single native `__FlushElementTree()` call — reproduced on Android across two frameworks, ruled out as hardware/device-specific

Author: carlos-swebCreated Sep 10, 2026Updated Sep 11, 2026

System Info

Output of npx envinfo --system --npmPackages '@lynx-js/*' --binaries (dev machine):

System:
  OS: Linux 7.1 Fedora Linux 44 (Workstation Edition)
  CPU: (8) x64 Intel(R) Core(TM) i3-10100F CPU @ 3.60GHz
Binaries:
  Node: 26.5.0
  npm: 11.17.0
npmPackages:
  @lynx-js/config-rsbuild-plugin: ^0.2.0 => 0.2.3
  @lynx-js/qrcode-rsbuild-plugin: ^0.7.0 => 0.7.1
  @lynx-js/rspeedy: ^0.17.0 => 0.17.1
  @lynx-js/template-webpack-plugin: ^0.16.0 => 0.16.0
  @lynx-js/type-element-api: 0.0.9 => 0.0.9
  @lynx-js/types: 4.1.0 => 4.1.0

Android host (native lynx artifact, the surface where the actual cost lives):

org.lynxsdk.lynx:lynx: 4.1.0

Test device (physical hardware, not an emulator):

Model: Samsung Galaxy A07 (SM-A075M)
Android: 16 (API 36)
Chipset: MediaTek MT6789 (mid-range)

Details

Summary: Applying a custom @font-face font-family to many text nodes on Android costs up to +2 seconds of cold-start time, and the entire cost lives inside a single native __FlushElementTree() call — not in JS. It reproduces identically in a hand-written framework and in official ReactLynx, and does not reproduce at all in a plain-Android control app using the same TTF file on the same device. That combination rules out our own code, our own framework, and the device/hardware as the cause, which is why we're filing this against lynx itself rather than against react-rsbuild-plugin or a userland library.

1. What we measured

Same physical device throughout (cold launches via adb shell am start -W, LaunchState: COLD confirmed each time, am force-stop between runs). Same custom font (Ubuntu Mono .ttf, self-hosted via @font-face, not lynx.addFont()). Same UI: a header (title + subtitle) plus a list of 7 rows, each with a label/value/unit text — 27 text-bearing elements total when the font is applied everywhere.

Build Font applied to Cold start
Hand-written framework (Mithril.js ported onto Lynx's Element PAPI; not an official Lynx package) on org.lynxsdk.lynx:lynx:4.1.0 1 node ~850-930 ms
Same, same font 2 nodes ~930-1030 ms
Same, same font 4 nodes ~1010-1130 ms
Same, same font 6 nodes ~1100-1600 ms (noise increasing)
Same, same font 8 nodes (1 + a 7×-repeated class) ~2.0-2.5 s
Same UI, same font, inherited via one CSS rule on an ancestor + enableCSSInheritance in @lynx-js/config-rsbuild-plugin ~27 nodes (nominally) Still ~2.2-2.6 s, AND inheritance only reached 2 of the ~27 nodes (stopped 2 levels down) — neither fast nor correct
Official ReactLynx (@lynx-js/react-rsbuild-plugin), same UI rebuilt from scratch, font-family set explicitly on the same ~10 leaf classes (no inheritance) ~27 nodes ~2.3-2.9 s
Plain native Android (Kotlin, zero Lynx dependencies), same 27 TextViews, Typeface.createFromAsset() on the same .ttf (full unsubsetted 205 KB file, i.e. larger than the one used above) 27 views ~572-664 ms
Same native Android app, Typeface.DEFAULT instead (no custom font) 27 views ~522-560 ms

So: native Android pays ~30-100 ms total for loading and applying a custom TTF to 27 views. Both Lynx-based builds pay ~1.4-2+ seconds for the same thing on the same device, and the cost scales with how many nodes/rules reference the custom font-family — it is not present with 0-2 nodes, and grows steeply from there.

2. Root-cause localization inside Lynx

We instrumented a local copy of our framework's DOM-shim (Date.now() around every __SetClasses, __AppendElement, __CreateView/__CreateText/__CreateRawText, and the final flush) and routed console.log to logcat via org.lynxsdk.lynx:lynx-service-log. For the "8 nodes / ~2.0-2.5 s" case above:

[TIMING] SetClasses(direct) 'Title Title--light' took 0ms
[TIMING] SetClasses(direct) 'Card-value Card-value--light' took 0ms
... (39 total class-assignment calls building the whole tree, every one 0ms)
[TIMING] flushTree: styleProxies=0ms __FlushElementTree=1433ms
[TIMING] TOTAL onRenderPage took 1437ms

Every JS-side operation that builds the tree (creating nodes, setting every class string, appending every node to its parent) reports 0ms. The entire 1433ms is inside the single native __FlushElementTree() call that runs once, after the tree is fully built. This is why we don't believe this is fixable from framework code: by the time __FlushElementTree() is called, the JS side has already finished all of its work in native time.

3. Why we're confident this isn't our code, our framework, or the device

  • Cross-framework: identical UI, identical font, identical device — an official ReactLynx build (compiled, using the officially supported react-rsbuild-plugin/element-template pipeline) is exactly as slow as our own hand-written PAPI-based framework. Whatever the mechanism, it lives below both frameworks, in the engine they both call into.
  • Cross-implementation on the hardware: a from-scratch, zero-Lynx native Android app doing the ostensibly equivalent operation (Typeface.createFromAsset + assign to 27 TextViews) on the same physical device pays essentially nothing extra. This is a mid-range MediaTek device (Galaxy A07), not a high-end phone — so "the engine is fine, it's just a slow phone" doesn't hold either: plain Android text rendering on this exact hardware is fast.
  • Scales with rule/node count, not with "is a custom font present": 1-2 nodes cost ~100-200ms extra (negligible); the same font on ~8+ nodes costs seconds. A per-node or per-rule cost inside font/style resolution during flush is consistent with every data point above.
  • enableCSSInheritance doesn't provide an escape hatch either: we tried consolidating to one CSS rule on an ancestor (relying on inheritance instead of many explicit rules) specifically to avoid the per-node cost. In our framework's compiled output this flag made inheritance stop propagating after 2 levels (visually broken — descendant text silently fell back to the system font) and did not recover the lost time. We are not filing that inheritance-depth issue formally here since we can't yet rule out our own framework's compiled output for that specific sub-symptom, but we mention it in case it's a related/relevant data point for whoever investigates the __FlushElementTree cost.

4. Why we think this is worth an issue rather than a "just use fewer custom fonts" workaround

Lynx's own pitch is fast, thin native bridging on top of platform primitives it doesn't reinvent (text rendering included). A workload that a from-scratch native Android app does in <100ms extra is taking Lynx (through two independent, officially-supported-and-not rendering paths) 1.4-2+ seconds on the same device for the same visual result. That gap is large enough, and reproducible enough across implementations, that we think it's a genuine engine-level inefficiency in custom @font-face resolution during __FlushElementTree() — most likely re-doing font-face decoding/lookup/shaping-setup work per referencing node or per referencing CSS rule instead of caching it once per resolved font-family value — rather than something app authors should have to route around.

Reproduce link

Not providing a public repro repository at this time. Everything above was measured across three from-scratch local projects built specifically for this investigation:

  1. A ReactLynx app (create-rspeedy --template react-ts) with the UI/font described above.
  2. A minimal Android host app (org.lynxsdk.lynx:lynx:4.1.0, no other Lynx artifacts) rendering that bundle from local assets.
  3. A zero-Lynx native Android control app with the equivalent Kotlin/View UI and the same .ttf.

The Reproduce Steps below are sufficient to rebuild (1)+(2) from scratch with the official tooling in a few minutes.

Reproduce Steps

Minimal steps to reproduce with ReactLynx (no custom framework needed — see table above, the official pipeline reproduces it too):

  1. npx create-rspeedy@latest my-repro --template react-ts, add a @font-face rule pointing at any .ttf (we used Ubuntu Mono), and set output.dataUriLimit high enough to inline it.
  2. Render a list of ~7-8 rows (or any layout with ~8+ distinct text elements), each with 2-3 <text> children, and apply the custom font-family to those text classes explicitly (not just one ancestor rule).
  3. Host the bundle from a plain Android app: LynxViewBuilder with only org.lynxsdk.lynx:lynx:4.1.0 (no lynxtextra, no devtool — we ruled those out separately), loading the template bytes off the UI thread via a custom AbsTemplateProvider.
  4. On a real device (we used a Samsung Galaxy A07 / MediaTek MT6789, Android 16): adb shell am force-stop <pkg> then adb shell am start -W -n <pkg>/.MainActivity, repeat 5x, read TotalTime. Compare against the same layout with the font-family declarations removed.
  5. Expected (per Lynx's own performance docs, which describe @font-face as the standard build-time-known-font pattern): cold start should be roughly unaffected by adding the font. Actual: cold start grows by roughly +1.4-2s once the font is referenced by ~8 or more text elements.

Happy to share more detail (logs, the exact CSS/layout used) on request.