Skip to main content

Performance

vajra-ui's styled components wrap headless primitives from @devraj-labs/vajra-ui-core (Box wraps CoreBox, which wraps React Native's View). Two extra function calls per node before you hit a host component is a fair thing to ask about — this page answers it with numbers instead of assertions.

Total mount time

Raw View — react-native
CoreBox — @devraj-labs/vajra-ui-core
Box (styled) — vajra-ui
0801602403201001,0005,00010,000

Median total mount time (ms) — grows with tree size because there's more work overall.

Overhead per node vs Raw View

CoreBox — @devraj-labs/vajra-ui-core
Box (styled) — vajra-ui
051015201001,0005,00010,000

This is the number that actually answers "does the wrapper cost scale?" — it doesn't.

Tree size = number of sibling nodes mounted at once · hover or focus a bar for the exact value · light-mode palette

Why the layering exists

vajra-ui-core owns layout/spacing math and has zero dependencies beyond React Native. vajra-ui owns theming (color tokens, spacing scale, dark mode) and is built on top of it. Splitting headless primitives from a styled layer is the same shape as Radix UI / Base UI / react-aria — it exists so the layout logic isn't duplicated per styled component, and so vajra-ui-core can be used standalone by anyone who wants primitives without a theme.

The benchmark

scripts/bench-box-layers.bench.tsx mounts trees of 100 / 1,000 / 5,000 / 10,000 sibling nodes for three cases and takes the median of 20 runs each, after a warm-up pass to let the JIT settle. 10,000 simultaneous sibling nodes is already well past what a real screen renders at once (a long scrollable form or dense list is a few hundred to low thousands) — it's included as a stress ceiling, not a realistic scenario.

  • Raw View — plain React Native <View>, no wrapper.
  • CoreBox@devraj-labs/vajra-ui-core's Box, one layer over View.
  • Boxvajra-ui's styled Box, two layers over View (resolves color/spacing tokens, then delegates to CoreBox).

Reproduce it yourself (it's excluded from the normal test run since it's a timing benchmark, not a correctness check):

npx jest --config jest.config.js --testMatch '<rootDir>/scripts/*.bench.tsx' --verbose

Results

Tree sizeRaw View (median ms)CoreBox (median ms)Box (median ms)Box vs View overhead
1001.002.002.00+1.00ms total, 10.00µs/node
1,00012.5021.0024.00+11.50ms total, 11.50µs/node
5,00068.00107.50132.00+64.00ms total, 12.80µs/node
10,000147.00244.50306.00+159.00ms total, 15.90µs/node

Per-node overhead stays in a 10–16µs band across two orders of magnitude of tree size — it doesn't compound, and it isn't hiding an accidental O(n²) somewhere in the theme resolution path. (We also tried 50k/100k nodes; past ~10k the numbers get dominated by Node's own GC pressure from repeatedly mounting/unmounting huge trees in one process rather than any real per-node cost, and swing wildly between runs — that's a benchmark-harness artifact at a scale no real screen hits, not a finding about the library, so it's excluded here.)

The absolute-time columns above grow with tree size because there's more work to do overall — at 10,000 nodes, Box takes ~2x longer in total than raw View. Read literally, that sounds alarming. But the per-node overhead column is the actual claim being tested, and it stays flat: Box isn't getting proportionally slower as the tree grows, there are just more nodes. The µs/node column — and the chart above, which plots exactly that — is the one to look at, not the ms totals.

For scale: a genuinely large real screen (a long settings list, a dense form) might mount a few hundred styled nodes at once. At 300 nodes that's roughly 3–4ms of extra reconciliation time for the entire screen, one time, at mount — not per frame, not per scroll, not per re-render of an unrelated node (see memoization below).

What this measures, and what it doesn't

This benchmark measures JS-side render/reconciliation cost via react-test-renderer. It does not measure native paint, layout, or the old RN bridge's serialization cost — those happen at the host-component (View) level regardless of how many JS wrapper functions sit above it, so they're identical across all three cases and not what the extra layer could possibly affect. If you're chasing a real frame-drop, profile with Flipper/the RN DevTools first — it is very unlikely to point at this layer.

Why it doesn't compound in practice

  • Every styled component is wrapped in React.memo. Re-renders where props are referentially unchanged skip reconciliation for that subtree entirely — the wrapper cost above only applies once, at initial mount or when props actually change.
  • The overhead is a function call, not I/O. There's no network request, no bridge round-trip, no synchronous layout pass hiding in the wrapper — it's token lookups (colors[bg], spacing[gap]) and a prop spread.

Bottom line

Two wrapper layers cost about 10µs per node at mount, don't compound with tree size, and are skipped on re-render for anything memoized with stable props.