[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
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.0Android host (native lynx artifact, the surface where the actual cost lives):
org.lynxsdk.lynx:lynx: 4.1.0Test 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 1437msEvery 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-templatepipeline) 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 27TextViews) 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.
enableCSSInheritancedoesn'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__FlushElementTreecost.
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:
- A ReactLynx app (
create-rspeedy --template react-ts) with the UI/font described above. - A minimal Android host app (
org.lynxsdk.lynx:lynx:4.1.0, no other Lynx artifacts) rendering that bundle from local assets. - 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):
npx create-rspeedy@latest my-repro --template react-ts, add a@font-facerule pointing at any.ttf(we used Ubuntu Mono), and setoutput.dataUriLimithigh enough to inline it.- Render a list of ~7-8 rows (or any layout with ~8+ distinct text elements), each with 2-3
<text>children, and apply the customfont-familyto those text classes explicitly (not just one ancestor rule). - Host the bundle from a plain Android app:
LynxViewBuilderwith onlyorg.lynxsdk.lynx:lynx:4.1.0(nolynxtextra, no devtool — we ruled those out separately), loading the template bytes off the UI thread via a customAbsTemplateProvider. - On a real device (we used a Samsung Galaxy A07 / MediaTek MT6789, Android 16):
adb shell am force-stop <pkg>thenadb shell am start -W -n <pkg>/.MainActivity, repeat 5x, readTotalTime. Compare against the same layout with thefont-familydeclarations removed. - Expected (per Lynx's own performance docs, which describe
@font-faceas 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.
Source: lynx-family/lynx