Performance & optimization.
Aktion is fast by default — the morph reconciler patches the live DOM in place and path-tracked atoms only invalidate their readers. The work that remains is avoiding needless full re-renders, helping the memoizer, and feeding the parser efficiently while streaming.
The short version
- Keep state on the fine-grained path — top-level atoms and
$storefields, written by narrow paths. - Pass stable props and give list rows a
keyso the memoizer can skip unchanged components. - Window long lists with
VirtualList/VirtualGrid; paginate big datasets. - Cache expensive work with
$memo; push heavy compute off-thread with$util.worker. - Stream with
appendChunk, neversetResponseper token. - Measure with DevTools before optimising — don't guess.
Measure first. Aktion is fast by default. 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.
1. Minimise full re-renders
Read the Reactivity & rendering page first: the single biggest lever is keeping updates on the fine-grained path. In short:
- 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. - Remember that
$state/$memo,$httptransitions, timers, and$emiteach force a full-tree re-render. Use them where the subtree is small or the update is infrequent.
2. Help the memoizer
On a fine-grained update, a PascalCase component whose props
are unchanged is skipped entirely (its previous DOM is
reused). To benefit:
- Pass stable prop values. A fresh object/array literal
rebuilt every render (
{ style: {…} }) defeats prop equality — hoist it or wrap the derivation in$memo. - Give list items a
keyso identity is preserved across reorders; without it, the memoizer and the morph reconciler must fall back to positional matching. - Use
$memo(() => expensive(), [deps])for filtering, sorting, and formatting so the work is skipped when inputs are unchanged.
The classic memo-defeating bug is rebuilding a derived array on every render. Cache it so the prop identity is stable:
// ❌ 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))])
}
Full re-renders run with memoization disabled by design (the tree is re-executed top to bottom). So memoization pays off in proportion to how few full re-renders you trigger.
3. The per-render safety budget
Every render runs under a budget that bounds three dimensions so a partial or accidentally-recursive program can never freeze the tab:
| Dimension | Default | Trips on |
|---|---|---|
componentDepth | 150 levels | Recursive trees, e.g. function Foo() { return Foo() }. |
iterations | 250 000 / render | Unbounded for/while loops in function bodies. |
arrayLength | 100 000 entries | $util.range(0, 1e9), $util.repeat(value, 1e9). |
When a limit trips, the render aborts, an error event fires
({ line: 0, column: 0, message }), and the previous tick’s
DOM is left intact. The defaults comfortably fit any realistic app; tune
them with createRuntimeBudget({ … }) passed through
createContext, or pass null to disable
enforcement in trusted offline pipelines.
4. Large lists
- Use
VirtualListfor long, fixed-height lists so only the visible window is in the DOM. - Use
VirtualGridfor large 2‑D grids — it mounts only the visible rows of cells, the same windowing idea applied to a grid (verified scrolling 300 items to keep just the on‑screen rows mounted). - Paginate or window very large datasets before rendering — a
100k-row table renders 100k rows of DOM. For server‑paged data, reach
for
$queryinfinite mode. - Keep per-row work cheap: precompute derived fields with
$memoor$utilrather than inside the row component.
Both components render an array of pre-built nodes as
items — map your data to component nodes first, then
hand the array over:
// Only the visible rows of a large grid are ever in the DOM.
VirtualGrid($cells.map(cell => Card([Text(cell.label)])), {
columns: 4,
itemHeight: 120,
})
$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())
4b. Offload heavy work to a Web Worker
Long synchronous computations block the main thread and stall rendering.
Move a pure function off‑thread with
$util.worker(fn, ...args) — it runs in a Blob‑URL Web
Worker and resolves with the result (falling back to an inline async run when
Workers aren’t available). The function is serialised, so it must not
close over outer variables — pass everything it needs as arguments.
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 })
}
4c. Lazy loading & code-splitting
Defer code you don’t need up front. Router arms already evaluate lazily;
wrap a dynamically‑imported chunk in
Lazy(loader, fallback?) to keep it out of the initial bundle and
render it on resolve. See Modules and
Routing.
pages = $router({
"/": Home(),
"/report": Lazy(() => import("./report.aktion"), Spinner()),
default: NotFound()
})
5. Streaming throughput: setResponse vs appendChunk
| Method | Use when | Cost |
|---|---|---|
setResponse(full) |
You have the complete program text (a finished message, a saved snippet). | Re-parses the whole document once and renders. |
appendChunk(delta) |
You are streaming tokens from an LLM as they arrive. | Incremental — parses only the new tail and re-renders the affected lines. This is the cheap path for token-by-token output. |
Don’t call setResponse on every token (it re-parses the
entire buffer each time). Stream with appendChunk and reserve
setResponse for whole-document swaps.
6. Bundle size
The runtime ships as a single bundle that registers the
<aktion-app> element and the full component library.
Because library components are resolved by name at runtime (an LLM may
emit any of them), the library is intentionally not tree-shaken
per app. To trim:
- Load the runtime from the CDN so it is cached across pages and apps.
- For self-hosted builds, import the runtime once and share it across
every
<aktion-app>on the page rather than per component. - Keep your own custom component packs in a separate module so they only load where used.
7. Profile with DevTools
The DevTools panel ships a commit profiler and a per-render “why did this render” reason. Use it to spot components re-rendering more than expected, then trace the cause back to a full-re-render trigger or an unstable prop.
8. Server-side rendering & SSG
Render a program to HTML ahead of time to cut time‑to‑first‑paint
and improve SEO. renderToString(program, opts) returns
{ html, state }; embed the html in your page shell and
serialise state into a <script> so the client can
hydrate without a flash of empty content. For fully static pages,
renderToStaticMarkup(program) returns just the markup.
import { renderToString } from "aktion-runtime"
const { html, state } = renderToString(programSource, { path: "/" })
// → inject `html` into the shell, ship `state` for hydration
The renderer is DOM‑based, so in Node you register a DOM shim
(happy-dom / jsdom) on globalThis before
calling it — the same setup the test suite uses. Pair the returned
state with the client’s hydration seam to avoid re‑fetching
on load.
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 →