Guide

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:

  1. Open the panel and reproduce the slow interaction once.
  2. 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.
  3. Open the components table and sort by self time. The top row is what to look at — not the component you suspected.
  4. 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), or memoized (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:

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:

IdentitySourceBehaviour 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.

ControlRuleConsequence
<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 }).

Live — the counter commits every second; keep typing in the field
$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:

PassOn a normal elementWith data-rui-preserve
Attribute removalAttributes the fresh tree omits are removed.Never — the widget’s own reflected attributes survive.
Attribute applyNew and changed attributes applied.Applied, so a reactive sx / class update still reaches the host.
Event handlersSynced.Synced.
ChildrenReconciled.Never touched.
Form stateSynced 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.

PropVirtualListVirtualGrid
itemsPre-built nodes, or plain rows plus renderItem.Pre-built nodes, or plain values.
itemHeightFixed row height in px. Default 40, floor 24.Cell height in px. Default 120, floor 24.
heightViewport height in px. Default 12 rows.Viewport height in px. Default 480, floor 120.
columnsCells per row. Default 4, clamped to 1–12.
minItemWidthMinimum cell width in px. Set it and the grid auto-fills and reflows instead of forcing columns.
gapGap between cells in px. Default 8.
renderItem(row, index) => Node, invoked per visible row.
onItemClickReceives the clicked row and its index.Receives the clicked item and its index.
empty / loadingNode 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.

Live — 2,000 rows, only the visible window is mounted
$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.

MethodUse whenWhat 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

LimitDefaultCounted acrossTrips 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.

ExportSignatureNotes
DEFAULT_RUNTIME_BUDGETReadonly<{ componentDepthLimit, iterationLimit, arrayLengthLimit }>The three defaults, without the running counters.
createRuntimeBudget(overrides?) => RuntimeBudgetAdds componentDepth: 0 and iterations: 0.
resetRuntimeBudget(budget) => voidClears the counters, keeps the limits. Call between renders.
createContext(state, options?) => EvaluationContextoptions.budget takes a RuntimeBudget, or null to disable.
RuntimeBudgetErrorclass extends ErrorCarries 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.

BoundLimitBehaviour past the limit
Markdown document128 KBTruncated, so the beginning still renders.
Markdown single line8 KBThat line is truncated.
Styles CSS payload64 KBSheet dropped, with a console.warn.
Styles scope selector128 charsSheet dropped, with a console.warn.
Inline SVG input / nodes / depth64 KB / 4096 / 32Rejected by the SVG sanitiser.
Rich-text HTML input / nodes / depth512 KB / 8192 / 64Rejected by the HTML sanitiser.
Custom icon markup16 KBRegistration is skipped.
Progress.segments200Clamped — it drives a DOM-creation loop.
Text.lines20Clamped.
CodeBlock.highlightLines rangethe block’s line count, hard ceiling 10 000Clamped, so "1-99999999" cannot build a huge set.
$util.match / $util.rules.pattern pattern1024 charsReturns false.
$util.match / $util.rules.pattern subject8 KBTruncated — only the first 8192 characters are tested.
State write array index1 000 000The write is a silent no‑op.
$util.range / $util.repeat100 000 entriesThrows 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:

EntryRawGzipBrotliShips to the browser?
dist/aktion.iife.js — CDN <script>1,780,638 B493,087 B (482 KB)387,471 B (378 KB)Yes.
dist/aktion.js — ESM, as published2,179,995 B563,229 B (550 KB)435,112 B (425 KB)Yes, after your bundler.
dist/aktion.js — after minification1,784,473 B492,813 B (481 KB)387,220 B (378 KB)This is what your users download.
dist/devtools.js — the 14‑tab panel308,963 B77,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 plugin87,286 B15,577 B (15.2 KB)13,542 B (13.2 KB)No — build time only.
dist/testing.js2,541,206 B600,797 B (587 KB)455,691 B (445 KB)No — test time only.
dist/language.js1,636,750 B402,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:

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

Next