Performance
Performance¶
The big idea¶
Wybthon component bodies run once. Reactive updates flow through reactive holes: a per-binding effect for each callable child or prop value. When a signal changes, only the holes that read it re-evaluate, and only the affected DOM nodes are touched. This is fundamentally cheaper than a full component re-render plus diff, even though we still use a VDOM internally to batch DOM mutations across the Python and JS bridge.
One bridge crossing per update¶
Under Pyodide, rendering cost is dominated by Python-to-JS FFI round
trips, not by the DOM operations themselves. Wybthon therefore never
calls DOM APIs directly: the renderer emits compact operations against
integer node ids into a command buffer, and each commit serializes the
whole buffer once for a small JavaScript kernel to apply natively. A
logical update (a render, an effect flush, an event handler) costs
one bridge crossing regardless of how many nodes it touches. Event
delegation also lives in the kernel, so a click dispatches to Python
once, carrying its payload as a single JSON string.
Template-based mounting¶
On top of the command buffer, the reconciler serializes each static host-element skeleton to HTML with text content hoisted out, registers it with the kernel once, and mounts every occurrence with a single clone op. Structurally-identical subtrees (list rows) share one template, so the browser parses the skeleton once and clones it per row, like SolidJS's compiled templates.
You get this for free; there's no opt-in. Subtrees that can't be
expressed as HTML (raw-text elements, adjacent text nodes, and
similar) fall back to per-node ops in the same batch, with identical
behavior. See the template API page.
Authoring tips¶
- Prefer holes over re-rendering. Embed a signal accessor (or
dynamic(lambda: ...)) at the smallest possible spot in the tree rather than reading signals eagerly inside the component body.
# Slow: eager reads happen during setup; future changes are missed.
return p(f"Hello, {name()}, count={count()}")
# Fast: only two text nodes update.
return p("Hello, ", span(name), ", count=", span(count))
-
Co-locate signals with the smallest visible region. A hole is cheaper than a
Show/Forre-evaluation, and aFor/Indexper-item scope is cheaper than adynamicreturning the full list. -
Use
create_memofor derived values. Memoised getters work out-of-the-box as reactive holes:span(my_memo). -
Use
keyon lists. Keyed reconciliation reorders existing DOM nodes in place instead of re-creating them. Prefer stable IDs over indices for keys. -
Batch updates with
batch(). When a single user action triggers multipleset()calls, wrap them inbatch()so all affected holes flush together. -
Use
For/Indexfor dynamic lists. These maintain stable per-item (or per-index) reactive scopes and cache the rendered subtree per item: on a list change, only added items map, removed items dispose, and reorders move the existing DOM nodes. -
Use
create_selectorfor selection state. A selector notifies only the previously-selected and newly-selected rows, so selecting a row in a 10,000-row table touches two rows instead of all of them. -
Use
reconcilefor server data. Diffing fresh data into a store (rather than replacing it) keeps identities stable, soForrows for unchanged items keep their DOM.
Micro-benchmarking¶
Run the included benchmarks against the stubbed DOM:
The app under test is built the idiomatic fine-grained way (mount once,
then drive everything through signal writes with For and
create_selector), so the numbers reflect what a well-written Wybthon
app pays. The runner measures:
- Standard JS framework benchmark workloads (create, update, swap, and remove rows in a 1k to 10k row table); useful as a regression smoke test for the list primitives and the template mount path.
hole update (1k tree): change one signal that drives a single reactive hole inside a 1,000-node tree.full rerender (1k tree): re-render the entire tree and let the diffing algorithm reduce the change set to one text node.
Both tree benchmarks update the same DOM node; the difference is entirely in what work the framework does to figure out the change. On the stubbed DOM (which makes every framework cost more visible than real browser DOM mutations) the gap is dramatic:
| Benchmark | Mean |
|---|---|
hole update (1k tree) |
~0.02 ms |
full rerender (1k tree) |
~12 ms |
Fine-grained operations show the same pattern in the table workloads:
selecting a row is ~0.02 ms (two class flips via create_selector) and
a partial update of every 10th label is under a millisecond, while
creating 1,000 rows from scratch, which must build and mount 1,000
subtrees, sits in the hundreds of milliseconds.
Use --bench=<name> to run a single benchmark or --json to emit
machine-readable output.
benchmarks/browser_bench.py runs the real browser app (Pyodide +
headless Chromium) for end-to-end numbers that include FFI and layout;
see benchmarks/README.md.
Next steps¶
- Read Mental model for the underlying ideas.
- Browse Authoring patterns for hole-friendly recipes.
- See Suspense and Lazy Loading for code-splitting.