Performance & optimization.
Aktion is fast by default — every commit re-renders the whole tree in memory, then a reconciler patches only the live DOM nodes that actually changed. The work that remains is avoiding needless full re-renders, keeping props stable enough to memoize, and not fighting the reconciler.
Measure first. Before you restructure anything, open DevTools, record a commit, and read the “why did this render” reason. Almost every real slowdown traces back to one of two causes: a full re-render you didn’t need, or an unstable prop defeating memoization.
Measure before you change anything
The DevTools panel is the only way to tell a full re-render apart from a fine-grained one, and that distinction decides which of the sections below applies to you. Work through it in this order:
- Open the panel and reproduce the slow interaction once.
- Read the commit strip. Wide, frequent commits mean full re-renders; narrow ones mean the fine-grained path is working and the cost is inside one component.
- Open the components table and sort by self time. The top row is what to look at — not the component you suspected.
- Read that row’s render reason. It is one of
initial mount,positional args changed,named args changed,state dependency changed,no memo (full render), ormemoized (args + deps unchanged).
The reason names the fix. named args changed on a component
whose data did not change is an unstable prop — go to
Help the memoizer. no memo (full render)
everywhere means something is forcing full re-renders — go to
Minimise full re-renders.
Minimise full re-renders
Aktion has two render gates. A write to a tracked state path invalidates only the components that read that path; anything the path tracker cannot see forces a full re-render of the whole tree, with memoization disabled. Read Reactivity & rendering for the mechanics; the levers are:
- Prefer top-level
$name = valueatoms and$storefields for shared state — writes invalidate only the readers of the exact path. - Write narrow paths (
$user.name = …) instead of reassigning whole objects.$user = { …$user, name }invalidates every reader of$user. - Remember that
$state/$memosetters,$httptransitions, timers, effect bodies, and$emiteach force a full re-render. Use them where the subtree is small or the update is infrequent.
Memoization is armed only when the change set is fully known — not on the first paint, not after a re-plan, and not when something forced the render. So the payoff from the next section is proportional to how few full re-renders you trigger.
Help the memoizer
On a fine-grained update, a PascalCase component is skipped
entirely — body not executed, previous output reused — when
all three of these hold: its positional args are unchanged, its named
args are unchanged, and none of the state paths it read last render
changed. Argument comparison is shallow
Object.is, per positional slot and per named key.
Shallow identity is the whole game. A fresh object or array literal built during render is a different value every time, so the component always re-renders even when the data is identical:
// ❌ filter runs every render; Table sees a "new" array each time
function Orders() {
return Table([Col("Customer", $orders.filter(o => o.open).map(o => o.name))])
}
// ✅ $memo caches the filtered rows until $orders changes
function Orders() {
open = $memo(() => $orders.filter(o => o.open), [$orders])
return Table([Col("Customer", open.map(o => o.name))])
}
With $memo the derived array keeps the same identity across
renders, so Orders reports
memoized (args + deps unchanged) in the profiler instead of
named args changed. The same reasoning applies to inline
sx objects and inline option arrays — hoist them to a
top-level binding or wrap them in $memo.
Give list items a key as well. Without one, a component
instance is identified by its source location plus its position in the
tree, so reordering a list shifts every instance onto a different
neighbour’s cached state.
$rows.map(row => OrderRow(row.name, row.total, { key: row.id }))
// key: is stripped before props are compared, and stamped on the
// rendered root as data-rui-key so the reconciler MOVES the node.
A keyed instance keeps its per-instance state, its focus, and its media playback position across a reorder. An unkeyed one does not.
The morph contract
Every commit builds a complete fresh tree and hands it to the morph reconciler, which walks the live DOM in parallel and keeps as many existing nodes as it can.
Its rules are worth knowing because breaking them produces the class of bug that reads as a performance problem: a field that clears itself, a drawer that snaps shut, a handler that goes stale.
The one sentence to remember
Attributes the fresh tree omits are removed. The reconciler makes the live node’s attributes match the freshly‑rendered one exactly, so any attribute your render does not emit is deleted on the next commit — and a commit can be triggered by anything, anywhere in the app.
Node identity and keys
A live node is reused when its node type and its tagName
both match the fresh node; otherwise it is replaced outright, losing
every piece of browser-owned state it held. Identity within a child list
is resolved in this order:
| Identity | Source | Behaviour on reorder |
|---|---|---|
#id |
The element’s id attribute (from the universal id prop). |
Matched and moved to its new slot. |
@key |
data-rui-key, stamped from the author’s key: prop. |
Matched and moved to its new slot. |
| positional | No id and no key — the child’s index. |
Patched in place; state stays with the slot, not the row. |
Surplus live children are removed from the tail of the list. An
id therefore doubles as a reconciliation key, which is
useful to know when two sibling components share one.
Uncontrolled form state
The contract for text-ish inputs is: an absent
value attribute means the render is not asserting a
value, so the live DOM value is left alone. An attribute that
is present — including value="" — is a
deliberate assertion and is applied.
| Control | Rule | Consequence |
|---|---|---|
<input type="file"> |
Never touched at all. | A FileList cannot be assigned, and value = "" would empty the user’s selection. |
| checkbox / radio | Skipped unless the fresh node carries checked or is itself checked. |
An uncontrolled checkbox no longer un-ticks itself on every commit. |
| text, email, number, … | Skipped when the fresh node has no value attribute. |
Typing survives a commit triggered from anywhere else in the app. |
<select> |
Applied whenever oldEl.value !== newEl.value. |
Children are reconciled first, so the value can resolve against freshly‑patched <option>s. |
If you write your own component, this is the rule you must honour: pass
null, never "", for an unset value. The
library helper valueAttr(value) does exactly that — it
returns null for null/undefined,
and el() skips null attributes.
// ❌ collapses "unset" to "", which morph applies as a deliberate clear
el("input", { type: "text", value: asString(props.value) })
// ✅ emits no value attribute at all when the prop is absent
el("input", { type: "text", value: valueAttr(props.value) })
The shipped form components route their value through
valueAttr for exactly this reason —
Input, SearchBar, NumberInput,
PasswordInput, MaskedInput,
DatePicker, DateRangePicker,
TimePicker, DateTimePicker,
InlineEdit and RichTextEditor among them.
Current limitation — uncontrolled TextArea and Select
Bind these two to state. The attribute guard above
protects <input> only. A <textarea>
and a <select> are compared against the fresh
node’s value property, which is always a
string — so “the render asserts nothing” is
indistinguishable from “the render asserts empty”.
Typed text in an uncontrolled TextArea, and the option a
user picked in an uncontrolled Select, are therefore still
lost on the next commit. The runtime tracks all three cases as
executable failing tests. Make them controlled instead:
TextArea({ value: $note, onChange: v => $note = v }).
$ticks = 0
$note = ""
$effect(() => { $ticks = $ticks + 1 }, ["every(1000)"])
$app(Column([
CardHeader("Uncontrolled vs controlled", { subtitle: "A commit fires every second" }),
Input({ id: "free", label: "Uncontrolled — no value prop", placeholder: "Type here; text survives" }),
TextArea({ id: "bound", label: "Controlled — bound to $note", value: $note, onChange: (v) => $note = v, rows: 2 }),
Text(`Commits so far: ${$ticks}`, { variant: "small", tone: "muted" })
], { gap: "md" }))
The first field keeps what you type because its render emits no
value attribute. The second keeps it because the program
owns the value and re-asserts it on every commit. Both carry an
id, which matters: the host’s focus backstop only
snapshots and restores the active element when it has one.
Focus, caret and IME
When the reconciler does write a value into a focused field it preserves the caret rather than bouncing it to the end. If the caret was already at the end it follows to the new end; otherwise both selection offsets are clamped into the new length, which is what makes a controlled transform such as uppercase-as-you-type usable.
Reading and writing the selection is wrapped in
try/catch, because number,
email, url and date inputs throw
on selectionStart. The value still lands; only the caret
restore is skipped.
The value sync is also deliberately not focus-gated. On macOS
clicking a button does not blur the input, so skipping focused fields
would silently drop a programmatic $input = "".
In-flight IME composition is safe for the same reason uncontrolled text
is safe — while the render asserts no value, morph
never writes, so there is nothing to interrupt.
Event handlers
Handlers are attached as DOM properties (el.onclick = fn),
never with addEventListener. That is what lets the fresh
closure be copied onto a kept node, so a handler always sees the current
props and state. Forty-seven handler properties are transferred, covering
pointer, keyboard, focus, drag, touch, scroll, form, animation and media
events.
A listener registered with addEventListener
cannot be transferred. If you attach one inside a custom
component, attach it once against a node you also mark
data-rui-preserve, or re-derive it from props on every
render as a property assignment instead.
Handing a subtree to imperative code
A third-party widget — a chart, a map, a rich-text editor, a
hydrated custom element — owns its own DOM, and reconciling it
would destroy it. Marking the host element
data-rui-preserve switches morph into a minimal mode:
| Pass | On a normal element | With data-rui-preserve |
|---|---|---|
| Attribute removal | Attributes the fresh tree omits are removed. | Never — the widget’s own reflected attributes survive. |
| Attribute apply | New and changed attributes applied. | Applied, so a reactive sx / class update still reaches the host. |
| Event handlers | Synced. | Synced. |
| Children | Reconciled. | Never touched. |
| Form state | Synced per the rules above. | Never touched. |
This is the contract behind Mount(…) and
WebComponent(…) — see
Interop.
A few attributes are element-owned even without the marker:
open on <details> (user-toggleable),
width/height on <canvas>
(removing them resets the drawing buffer and erases the bitmap), and the
popover/style/data-floating-side
trio on a popup that is currently promoted to the browser top layer.
The last one has a consequence worth stating plainly: while a dropdown, tooltip or popover panel is open, a re-render cannot restyle that panel’s root. Ownership returns automatically when the panel closes.
Never store UI state on the live DOM
This is the single rule component authors break most often. An event
handler that writes class, style or a
data-* attribute straight onto the live DOM is describing a
UI state the next render cannot reproduce.
So the next commit removes it again. The drawer closes itself, the dragged divider snaps back, the “Copied!” label reverts mid‑timeout.
// ❌ the next commit — from any state change anywhere — reverts this
onclick = () => { panel.setAttribute("data-open", "true") }
// ✅ put the bit somewhere the render reads
onclick = () => { slot.set({ open: true }) } // helpers.useInstanceState
// …then emit data-open from slot.get().open during render.
Treat an imperative write as a paint‑time optimisation, never as
the state. For a $‑bound prop use
helpers.setState; otherwise use
helpers.useInstanceState(key, initial).
Strict mode catches this for you. With
the strict attribute on <aktion-app> a
MutationObserver snapshots handler writes immediately
before each reconcile and re-checks immediately after, then warns once
per program naming the reverted attribute and the element.
Large lists
A 100k-row table renders 100k rows of DOM, and no amount of memoization makes that cheap. Window it, page it, or precompute it.
VirtualList and VirtualGrid
Both components take an array of pre-built nodes as
items and mount only the visible window, so map your data
to component nodes first and hand the array over.
| Prop | VirtualList | VirtualGrid |
|---|---|---|
items | Pre-built nodes, or plain rows plus renderItem. | Pre-built nodes, or plain values. |
itemHeight | Fixed row height in px. Default 40, floor 24. | Cell height in px. Default 120, floor 24. |
height | Viewport height in px. Default 12 rows. | Viewport height in px. Default 480, floor 120. |
columns | — | Cells per row. Default 4, clamped to 1–12. |
minItemWidth | — | Minimum cell width in px. Set it and the grid auto-fills and reflows instead of forcing columns. |
gap | — | Gap between cells in px. Default 8. |
renderItem | (row, index) => Node, invoked per visible row. | — |
onItemClick | Receives the clicked row and its index. | Receives the clicked item and its index. |
empty / loading | Node or text for an empty items; loading shows a pending state instead. | Same. |
// Only the visible rows of a large grid are ever in the DOM.
VirtualGrid($cells.map(cell => Card([Text(cell.label)])), {
minItemWidth: 180, // prefer this over `columns` — it reflows on narrow screens
itemHeight: 120, // 120 is the default
})
Scrolling swaps the mounted window rather than growing the DOM, so cost
stays flat as items grows. Prefer
minItemWidth to a fixed columns: four columns
on a phone gives you 55 px cells.
Both components box every row at itemHeight so the
virtualization maths matches the real layout — neither one measures
variable-height rows.
$rows = $util.range(1, 2000).map(n => Row([
Badge(`#${n}`, "primary"),
Text(`Row ${n}`),
], { gap: "sm" }))
function Big() {
return Card([
CardHeader("Virtualized list", { subtitle: "Scroll — the DOM holds only a handful of rows" }),
VirtualList($rows, { itemHeight: 44 })
])
}
$app(Big())
Paged and server-windowed data
Windowing helps the DOM; it does not help the network or the parser. If
the dataset itself is large, page it at the source with
$query infinite mode and
append pages as the user scrolls.
Keep per-row work cheap too. Derive display fields once with
$memo or $util outside the row component, so
formatting a currency or a date happens per page rather than per row per
render.
Offload heavy work to a worker
Long synchronous computation blocks the main thread and stalls every
commit behind it. Move a pure function off‑thread
with $util.worker(fn, ...args), which serialises
fn with toString(), runs it in a
Blob‑URL Web Worker, and resolves with the result.
$result = 0
function crunch() {
$util.worker((n) => {
let total = 0
for (let i = 0; i < n; i++) total += Math.sqrt(i)
return total
}, 5e7).then(r => { $result = r })
}
$app(Column([
Button("Crunch 50M square roots", { onClick: crunch, variant: "primary" }),
Text(`Result: ${$result}`)
], { gap: "md" }))
Because the function is serialised it must not close over outer
variables — pass everything it needs as arguments. When
Worker, Blob or
URL.createObjectURL is unavailable, the call falls back to
running inline (still async), so the promise contract never changes.
The worker is created from a blob: URL, so a strict
Content Security Policy needs
worker-src blob: for the off‑thread path to work.
Deferred rendering
Lazy(loader, { fallback }) defers work: it renders
fallback while the loader is pending, then renders whatever
the loader resolved. Reach for it when a panel is expensive to build and
is not visible on first paint.
$app(Column([
Text("Dashboard", { variant: "heading" }),
Lazy(() => Report(), { fallback: Skeleton({ lines: 6 }) })
], { gap: "md" }))
function Report() {
return Card([CardHeader("Quarterly report"), Text("…heavy content…")])
}
The loader runs exactly once per instance — the
settled result is kept in per-instance state, so a re-render does not
re-run it. A synchronous return value renders immediately; a promise
shows the fallback first. Add error,
onError and retry for the rejection path.
Aktion has no dynamic import() expression.
import { … } from "./ui.aktion" is resolved at build or
link time, so a code-split chunk is something your host
bundler produces, not something a program requests at runtime. See
Modules for how the two layers fit
together.
Streaming throughput: setResponse vs appendChunk
Both host methods replace the program text and schedule a render on the next microtask, and both re-parse the whole buffer when they do. The difference is how much state they throw away.
| Method | Use when | What it keeps |
|---|---|---|
setResponse(full) |
You have the complete program text — a finished message, a saved snippet, a new document. | Nothing. Every state atom is rebound to its declared default, every per-instance UI state slot is dropped, every disposer runs, the memo cache is cleared, and queued parse errors are reset. A no-op when the text is unchanged. |
appendChunk(delta) |
You are streaming tokens from an LLM as they arrive. | Everything. State values, per-instance UI state, effects and the memo cache all survive, so the growing program keeps its scroll position, its open tab and its typed input. |
So calling setResponse per token is not merely a slower
parse — it resets the app on every token. Stream with
appendChunk and reserve setResponse for
whole-document swaps.
const app = document.querySelector("aktion-app");
app.streaming = true; // suppress the error banner mid-stream
for await (const delta of tokens) {
app.appendChunk(delta); // renders coalesce into one per task
}
app.streaming = false; // now parse errors surface
Renders are coalesced: the host schedules at most one render per
microtask, so appending many chunks inside one task costs one render, not
one per chunk. Setting streaming also defers the
error event, because a mid‑token tail almost always
fails to parse — see Parse errors.
The per-render safety budget
Every render runs under a budget so a partial or accidentally-recursive program cannot freeze the tab. This page is the reference for those limits; Error handling covers how a trip is surfaced.
What the budget bounds
| Limit | Default | Counted across | Trips on |
|---|---|---|---|
componentDepthLimit |
150 | Simultaneous depth of user-component invocations. | function Foo() { return Foo() } — direct or transitive recursion. |
iterationLimit |
250 000 | Every for, for…of, for…in, while and do…while body evaluation in the whole render. |
An unbounded loop, or many nested loops that multiply out. |
arrayLengthLimit |
100 000 | Carried on the budget object — see the note below. | — |
The counters are cumulative for the pass and reset before each render.
Because they are cumulative across all loops, a thousand small
loops still get caught before they add up to seconds of work. When a
limit trips the evaluator throws RuntimeBudgetError, the
render aborts, and the previous tick’s DOM is left on screen:
[aktion] runtime aborted at component "Foo": component recursion exceeded 150 levels
— check for a component that calls itself directly or transitively.
[aktion] runtime aborted at `while` loop: exceeded 250000 total loop iterations
in a single render — narrow the iterable or split the loop.
The error carries kind
("component-depth", "iterations" or
"array-length"), limit and source,
and the host turns it into a banner line plus an error event
with { line: 0, column: 0, message }.
Two gaps to know about
Array methods are not counted. The iteration counter
only sees loop statements, so
$rows.map(…) over a 100k array costs nothing
against the budget. Windowing, not the budget, is what protects you
there.
arrayLengthLimit is not the knob it looks
like. The @Range/@Repeat cap is
enforced inside $util as a fixed 100 000, and it
throws a plain RangeError rather than a
RuntimeBudgetError:
$util.range refusing to allocate 300001 entries (limit
100000).
That error is logged and the tree renders empty — no banner, no
error event. Raising arrayLengthLimit on a
custom budget does not raise the $util cap.
Tuning the budget
<aktion-app> always uses the defaults — there is
no attribute for this. The limits are only reachable if you drive the
evaluator yourself, by building a budget and passing it into a context.
Both functions are public exports.
import { createRuntimeBudget, createContext, DEFAULT_RUNTIME_BUDGET } from "aktion-runtime";
DEFAULT_RUNTIME_BUDGET;
// → { componentDepthLimit: 150, iterationLimit: 250000, arrayLengthLimit: 100000 }
// Every field is optional; omitted fields fall back to the default.
const budget = createRuntimeBudget({ iterationLimit: 1_000_000 });
// → { componentDepthLimit: 150, iterationLimit: 1000000,
// arrayLengthLimit: 100000, componentDepth: 0, iterations: 0 }
const ctx = createContext(state, { library, budget }); // caller-supplied limits
const trusted = createContext(state, { budget: null }); // enforcement OFF
Omitting budget gives you a fresh budget with the default
limits; null disables enforcement entirely. Only reach for
null in a trusted offline pipeline — a server-side
batch render of your own programs, for example — never for program
text you did not write.
| Export | Signature | Notes |
|---|---|---|
DEFAULT_RUNTIME_BUDGET | Readonly<{ componentDepthLimit, iterationLimit, arrayLengthLimit }> | The three defaults, without the running counters. |
createRuntimeBudget | (overrides?) => RuntimeBudget | Adds componentDepth: 0 and iterations: 0. |
resetRuntimeBudget | (budget) => void | Clears the counters, keeps the limits. Call between renders. |
createContext | (state, options?) => EvaluationContext | options.budget takes a RuntimeBudget, or null to disable. |
RuntimeBudgetError | class extends Error | Carries kind, limit, source. |
Data-driven bounds
The budget bounds the program. A second set of fixed bounds protects the render thread from a single oversized value — an LLM reply, a pasted payload, an API field — because every one of these drives a loop whose cost grows with its input.
| Bound | Limit | Behaviour past the limit |
|---|---|---|
Markdown document | 128 KB | Truncated, so the beginning still renders. |
Markdown single line | 8 KB | That line is truncated. |
Styles CSS payload | 64 KB | Sheet dropped, with a console.warn. |
Styles scope selector | 128 chars | Sheet dropped, with a console.warn. |
| Inline SVG input / nodes / depth | 64 KB / 4096 / 32 | Rejected by the SVG sanitiser. |
| Rich-text HTML input / nodes / depth | 512 KB / 8192 / 64 | Rejected by the HTML sanitiser. |
| Custom icon markup | 16 KB | Registration is skipped. |
Progress.segments | 200 | Clamped — it drives a DOM-creation loop. |
Text.lines | 20 | Clamped. |
CodeBlock.highlightLines range | the block’s line count, hard ceiling 10 000 | Clamped, so "1-99999999" cannot build a huge set. |
$util.match / $util.rules.pattern pattern | 1024 chars | Returns false. |
$util.match / $util.rules.pattern subject | 8 KB | Truncated — only the first 8192 characters are tested. |
| State write array index | 1 000 000 | The write is a silent no‑op. |
$util.range / $util.repeat | 100 000 entries | Throws RangeError; the tree renders empty. |
These are ceilings, not targets — anything a real app renders fits far below them. Security & the trust model explains why they exist: they are what stops untrusted data flowing through a trusted program from freezing the host page.
Bundle size
The runtime ships as one bundle that registers the
<aktion-app> element, the component library, the
themes and the interpreter. Because components are resolved by name at
runtime — an LLM may emit any of the 282 — the library is
intentionally not tree-shaken per app.
Measured on the published build with gzip -9 and
brotli -q 11:
| Entry | Raw | Gzip | Brotli | Ships to the browser? |
|---|---|---|---|---|
dist/aktion.iife.js — CDN <script> | 1,780,638 B | 493,087 B (482 KB) | 387,471 B (378 KB) | Yes. |
dist/aktion.js — ESM, as published | 2,179,995 B | 563,229 B (550 KB) | 435,112 B (425 KB) | Yes, after your bundler. |
dist/aktion.js — after minification | 1,784,473 B | 492,813 B (481 KB) | 387,220 B (378 KB) | This is what your users download. |
dist/devtools.js — the 14‑tab panel | 308,963 B | 77,278 B (75.5 KB) | 63,549 B (62.1 KB) | Only when you import it. Minifies to 203,947 B / 61,934 B gzip. |
dist/plugin.js — Vite plugin | 87,286 B | 15,577 B (15.2 KB) | 13,542 B (13.2 KB) | No — build time only. |
dist/testing.js | 2,541,206 B | 600,797 B (587 KB) | 455,691 B (445 KB) | No — test time only. |
dist/language.js | 1,636,750 B | 402,341 B (393 KB) | 304,874 B (298 KB) | Only in editor / tooling surfaces. |
The published ESM entry keeps its newlines because bundlers minify downstream; run it through esbuild or terser and it lands within 300 bytes of the pre-minified IIFE. Plan for roughly 480 KB gzipped or 380 KB brotli for the runtime, once, shared by every app on the page.
There is no smaller build to pick, so the levers are all about paying for it once:
- Serve it from a CDN so it is cached across pages and across apps.
- Enable brotli at the edge — that is a 105 KB saving over gzip on the same file.
- Import the runtime once per page and share it across every
<aktion-app>, rather than per component. - Keep your own custom component packs in a separate module so they only load on the routes that use them.
- Import
aktion-runtime/devtools,/testand/languageonly where you actually need them — none of the three is pulled in by the main entry.
Server-side rendering & SSG
Rendering a program to HTML ahead of time cuts time‑to‑first‑paint
and makes the page crawlable. renderToString(program, options?)
returns four fields:
import { renderToString } from "aktion-runtime"
const { html, state, head, headAttrs } = renderToString(programSource, { path: "/" })
// html → inject into the shell's container
// state → serialise into a <script> for hydration
// head → inject into the shell's ("" when no $head ran)
// headAttrs → spread onto , e.g. { lang: "en", dir: "ltr" }
For a fully static page, renderToStaticMarkup(program)
returns only the markup. Document head is the
reference for what head and headAttrs can
contain.
The renderer is DOM‑based, so in Node you register a DOM shim
(happy-dom or jsdom) on
globalThis before calling it — the same setup the test
suite uses. Pair the returned state with the client’s
hydration seam so the first paint does not re‑fetch.
renderToString evaluates the program.
On a server that makes untrusted program text remote code execution,
not merely XSS. If the text is not yours, call
setGlobalAccessPolicy("safe") in the server entry before
rendering and isolate the process — see
Security & the trust model.
Performance checklist
- Measure with DevTools and read the render reason before changing anything.
- Keep state on the fine-grained path — top-level atoms and
$storefields, written by narrow paths. - Pass stable props; wrap derived arrays and objects in
$memoso their identity survives a render. - Give list rows a
keyso instance state, focus and animation travel with the row. - Emit no
valueattribute for an unset field, and bindTextArea/Selectto state. - Never store UI state on the live DOM — use
helpers.setStateorhelpers.useInstanceState. - Window long lists with
VirtualList/VirtualGrid; page big datasets at the source. - Push heavy compute off-thread with
$util.worker; defer expensive panels withLazy. - Stream with
appendChunk, neversetResponseper token. - Serve the runtime once, from a CDN, with brotli enabled.
- Turn on
strictin development so reverted DOM writes and unknown identifiers announce themselves.
Next
Reactivity & rendering
The two render gates and exactly what forces a full re-render.
Read the guide → GuideTroubleshooting / FAQ
Focus loss, effects not firing, memoized-away components, and more.
Read the guide → AdvancedDevTools
State inspector, commit profiler, and the effect timeline.
Open the guide →