Guide

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 $store fields, written by narrow paths.
  • Pass stable props and give list rows a key so 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, never setResponse per 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:

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:

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:

DimensionDefaultTrips on
componentDepth150 levelsRecursive trees, e.g. function Foo() { return Foo() }.
iterations250 000 / renderUnbounded for/while loops in function bodies.
arrayLength100 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

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,
})
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())

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

MethodUse whenCost
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:

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