Performance
Pyreon is fast by default: a signal change updates exactly the DOM that depends on it, with no component re-render and no virtual-DOM diff. Most apps need no tuning. This guide covers the few levers that matter when you push scale.
The default you get for free
Fine-grained updates.
{() => count()}patches one text node, not a component subtree.Compiled templates. JSX lowers to
cloneNode+ direct bindings — zero VNode allocations on the hot path.Components run once. No re-render means no re-render cost; the work is proportional to what changed.
Virtualize large lists
A 10k-row <For> allocates ~10k signals + cleanup closures. The signal struct is already near its memory floor, so the lever is to render only what's visible with @pyreon/virtual:
import { useVirtualizer } from '@pyreon/virtual'
function BigList(props: { rows: Row[] }) {
let scrollEl: HTMLDivElement | null = null
// Options are a function, so signal/prop reads inside them stay reactive.
const v = useVirtualizer(() => ({
count: props.rows.length,
estimateSize: () => 32,
getScrollElement: () => scrollEl,
}))
return (
<div ref={(el) => (scrollEl = el)} style={{ height: '400px', overflow: 'auto' }}>
<div style={() => `height: ${v.totalSize()}px; position: relative`}>
<For each={() => v.virtualItems()} by={(item) => item.index}>
{(item) => (
<div style={`position: absolute; top: ${item.start}px; width: 100%`}>
<Row row={props.rows[item.index]} />
</div>
)}
</For>
</div>
</div>
)
}Lazy-load heavy modules
Heavy packages (@pyreon/charts, code, flow, document) should not sit in the initial bundle if they're only used on interaction. Use a dynamic import inside the handler:
async function onExport() {
const { render } = await import('@pyreon/document') // chunk loads on click, not on page load
render(doc, 'pdf')
}@pyreon/lint's no-heavy-import-only-in-handler flags a heavy static import used solely in a handler. For components, lazy(() => import('./Heavy')) code-splits with Suspense.
Batch and select
batch(fn)— coalesce 3+ writes into a single update pass.createSelector(source)— O(1) keyed membership for "is this row selected?" across large lists, instead of O(n) per row.
import { batch, createSelector } from '@pyreon/reactivity'
const isSelected = createSelector(selectedId) // each row: isSelected(row.id) is O(1)
batch(() => { a.set(1); b.set(2); c.set(3) }) // one passMeasuring
In your app: the browser's Performance panel for wall-clock, and
@pyreon/testing'sexpectEffect/expectSignalmatchers to assert how often an effect re-runs (a fire-count regression is usually the first sign of a reactivity problem).In the Pyreon monorepo itself (not published to npm):
@pyreon/perf-harnessdev-mode counters, theexamples/benchmarkrow-list suite, andbun run measure-memoryfor retained bytes per primitive.
Common pitfalls
Imperative work in
effect()at setup.effect(() => fetch(...))runs synchronously during setup and allocates per instance — put fetches/timers/listeners inonMount. (no-imperative-effect-on-createflags this.)A 10k-row list without virtualization. Virtualize; don't try to make signals smaller.
Statically importing a heavy module used only on click. Dynamic-import it in the handler.
Related
Why Pyreon — the honest benchmark story