Koru in the Browser: How a Compiler Builds the Framework
The standard browser benchmark builds a table of ten thousand rows, updates every tenth one, swaps two, removes one, selects one, and clears the lot. It exists so that framework authors can argue with numbers instead of adjectives.
Koru compiles to JavaScript, so it can be measured on it. It lands at 1.051 against hand-written vanilla. That number is at the bottom of this post, with the caveats it has earned.
The question worth the post is the other one: what does it actually take to make a language render a web page? And the honest answer includes what it took to find out that the first three answers were wrong.
The entire surface is five declarations
This is the whole public interface of koru/dom:
pub tor run { title: string }
! ?click { action: i64, id: i64, key: i64 } pub tor drop { key: i64 }
pub tor drop-all {} Plus one more, which is where the work happens:
[comptime|transform|pre]pub tor component { Start the page and receive clicks. Let go of one element, or of all of them. Declare a component. That is the library.
There is no virtual tree, no diff, no scheduler, no reactive graph, no component
lifecycle with eight hooks in it. There is also no runtime: component is
declared comptime|transform, meaning it is consumed while the program is being
compiled and has no existence afterwards. What ships is the code it wrote.
The application
The benchmark app is 59 lines of Koru and 26 lines of JavaScript, counting real lines rather than comments and blanks. The compiler turns that into about 750 lines of JavaScript, and no DOM code in it was written by a person.
Here is a row:
koru/dom:component(Row) {
<tr>
<td class="col-md-1">{{ id:d }}</td>
<td class="col-md-4"><a data-action="8">{{ label:s }}</a></td>
<td class="col-md-1"><a data-action="7"><span class="glyphicon glyphicon-remove" aria-hidden="true"></span></a></td>
<td class="col-md-6"></td>
</tr>
} The static structure becomes a <template> built once when the module loads. The
placeholders become the typed inputs of a synthesized event, so painting a row is
a call with an id and a label. Lowercase tags pass through untouched — a
capitalised tag is a component, anything else is not inspected.
What comes out is a clone of that template with three fields filled in. It is the same shape the hand-written reference uses, which is neither a coincidence nor an aspiration: it is what you get when the description is static enough to be resolved before the program runs.
Three lines connect it to data
! inserted { id, label, [id]h } |> Row(parent: "#tbody", key: h, id, label)
! removed { [id]h } |> koru/dom:drop(key: h) |> std/store:stored { cnt.n: cnt.n - 1 }
! cleared _ |> koru/dom:drop-all() |> std/store:stored { cnt.n: 0 } When a row is born, paint one. When it dies, let its element go. When the store is emptied, let all of them go.
That is the entire lifecycle of every row on the page, written roughly the way you would say it out loud. Nobody subscribes to anything and nothing is observed. The store already knows when a row appears and disappears, so those moments are declarable — and the application declares what they mean.
Updating is the same declaration, read again
[name(tenth)]std/store:rule(rows)
! row { [row]e, [id]h } when @mod(e.pos, 10) == 0 |> if(op.code == 4)
| then |> std/store:stored { e.label: "{{ e.label:s }} !!!" } |> Row-repaint-label(key: h, e.label)
| else |> _ Row-repaint-label was never written by anyone. The compiler synthesized it from
the same markup that paints the row, one per property: it finds the element that
row already owns and re-runs only the assignments that mention label.
This is what the markup surface buys. There is exactly one description of a row in the program, so changing the markup changes painting and updating together. They cannot drift, because there is nothing to drift from.
Granularity mattered more than expected. Repainting the whole row on a partial update measured 13% worse than repainting the single property that changed. The coarser design was the obvious one, and it would have shipped without the measurement.
The thing that makes it work is identity
[id]h in those arms is a handle — the store’s own address for a row,
carrying a slot and a generation. The store compacts: removing a row swaps the
last one into the freed slot, so a row’s position is not its identity. The handle
is, and it survives the move.
So the component is keyed on the handle when the row is born, the library remembers which element it painted under it, and a click landing inside a row carries that handle back out. Nothing on either side ever searches the page for a row.
It was not always so, and the before-and-after is the clearest thing in the project. Rows used to carry their identity written into them as text. A click read that number back off the page, and then the program scanned every row in the store comparing identifiers — to find the row the user had just physically pointed at. Deleting that scan took removal from 5% slower than hand-written to parity, and taking identity out of the markup improved seven of nine operations at once: fewer attributes is less work when a row is born and a smaller page for everything that touches it afterwards.
The lesson generalises past browsers. A value both sides already hold is cheaper than a value either side can look up.
What is left in JavaScript, and why
Twenty-six lines, in three procedures, each there for a named reason rather than by default. One makes the benchmark’s random three-word label — randomness belongs to the host by nature, not by any gap in the language. One sets a class for selection, which could be a store write plus a repaint and, at parity with hand-written already, is not obviously worth the machinery.
The third is an honest hole:
proc swap-rows|js {
const ta = domRow(a);
const tb = domRow(b);
const parent = ta.parentNode;
const afterB = tb.nextSibling;
parent.insertBefore(tb, ta);
parent.insertBefore(ta, afterB === ta ? tb : afterB);
} Swapping two rows changes the parent’s child order. Nothing in a row’s own markup describes that, so no repaint can express it; a keyed-list reorder is its own primitive and does not exist yet.
Both remaining escapes reach their elements through the library’s own registry, by the same handle the store issued. Neither searches the document.
The application had been rendering the wrong text
Here is the part a launch post would leave out.
A row’s label lived in a char[40] column. The benchmark’s word lists top out at "inexpensive yellow sandwich" — 27 characters — and the partial-update
operation appends " !!!" four times, for 16 more. Forty-three is the real
maximum. Every label of 25 characters or more silently lost its last mark:
roughly twelve rows in a thousand, on every run, rendering text nobody wrote.
The reference harness asserts the exact string and aborts. Ours did not, and the reason is the useful part: the in-tree conformance gate clicks update once and checks that the label merely contains a mark. One mark is four characters and nothing overflows. Four is the reference’s number. So the app read eleven out of eleven on its own gate while failing the real harness about a quarter of the time.
A gate cheaper than the thing it mirrors is green by construction.
The column is now char[48] and the arithmetic sits beside the declaration. But
widening it is a workaround dressed as a fix, because the underlying behaviour is
unchanged: a fixed-width text column truncates without telling anyone. Sizing
one is an arithmetic prediction about your own data — longest input, plus every
suffix any code path may ever append — and a wrong prediction is paid in silent
wrong output rather than a refusal. That is precisely the failure class this
language exists to turn into a compile error, running backwards inside its own
standard library. Whether a write that does not fit should be a refusal, a
branch, or a compile-time obligation on the writer is open.
Two compiler bugs, found by using the compiler
Neither was found by looking for bugs.
A recycled row address stopped being unique. A handle packs a slot, a store brand and a generation into one integer. The native backend does that with bit operations and is exact everywhere; the JavaScript backend has no 64-bit bitwise operations, so it assembles the same layout by multiplying — and that multiplication stops being exact past about two million reuses of a single slot. Beyond it, two different live rows encode to the same address and a read answers with the wrong row. The standard library carried a comment asserting the two backends were equivalent here; every clause of it was true and the conclusion was false.
A safety net was installed in only one position. When you call something that can fail and decline to write the failure arm, Koru synthesizes a loud one so the path cannot silently proceed. The pass that installs it read a flow’s own outcomes and never descended into them — so the guarantee held at the top of a chain and evaporated one step in, which is where most calls are written. Both backends were affected. On the native target the program ran to completion and exited zero.
That one hid for a reason worth keeping: a missing synthesized arm is indistinguishable from a call that genuinely has one outcome, so both emitters correctly applied their own rule — a lone outcome always fires, no dispatch needed — and optimised the absent guard away. Any pass whose output is the absence of code needs its coverage pinned at every position, because absence is the one output every downstream stage will read as intent.
The numbers
Seven builds, nine operations, twenty-five iterations, one window, one machine with its background services shut down for the duration. Hand-written vanilla is the reference at 1.000 and every other column is a multiple of it.
| operation | vanilla ms | vanilla | twin | Koru | Solid 1.9.3 | Svelte 5.42.1 | Vue 3.5.39 | React 19.2 |
|---|---|---|---|---|---|---|---|---|
| build 1,000 rows | 28.1 | 1.000 | 0.996 | 1.025 | 1.021 | 1.048 | 1.210 | 1.279 |
| replace all rows | 30.5 | 1.000 | 1.007 | 1.069 | 1.092 | 1.115 | 1.246 | 1.385 |
| update every 10th | 15.4 | 1.000 | 1.006 | 1.136 | 1.091 | 1.110 | 1.247 | 1.455 |
| select a row | 3.6 | 1.000 | 0.944 | 0.917 | 1.194 | 1.778 | 1.361 | 1.944 |
| swap two rows | 17.5 | 1.000 | 1.011 | 1.017 | 1.126 | 1.149 | 1.183 | 7.606 |
| remove one row | 14.1 | 1.000 | 1.000 | 1.028 | 1.035 | 1.057 | 1.262 | 1.156 |
| build 10,000 rows | 288.5 | 1.000 | 1.000 | 1.077 | 1.095 | 1.089 | 1.232 | 2.041 |
| build 1,000 more | 31.9 | 1.000 | 1.000 | 1.006 | 1.028 | 1.034 | 1.157 | 1.238 |
| clear 1,000 rows | 12.4 | 1.000 | 0.992 | 1.210 | 1.250 | 1.137 | 1.395 | 1.895 |
| geometric mean | 1.000 | 0.995 | 1.051 | 1.101 | 1.152 | 1.253 | 1.812 |
The twin column is the point of the table. It is the reference’s own vanilla
implementation, byte for byte, served at a second URL and measured in the same
run. It should be 1.000 and it comes out at 0.995. The two copies agree to
between 0.0% and 1.1% on eight operations and 5.6% on selecting a row — which
takes 3.6 milliseconds, and is therefore the honest floor for how finely anything
in this table can be read. Without that column the rest is assertion.
Koru, Solid and Svelte are separated by less than one machine’s spread across a night. Treat the ordering among those three as a property of this run rather than of the libraries. The distance to Vue and React is large enough to survive it.
What the table is not. It is one benchmark implementation of one table, in each system’s own idiom. Solid, Svelte, Vue and React are general-purpose libraries carrying ecosystems, server rendering, devtools and a decade of production use between them; Koru’s entry is a compiler backend with a markup surface and no ecosystem at all. React’s 7.6 on swapping rows and 1.9 on selecting one are its reconciliation model meeting the two operations that suit it least — a keyed reorder and a single-row class change both make it revisit a list it cannot know is otherwise unchanged — and its build numbers, where that model does what it was designed for, are unremarkable.
What the numbers cost to get
Three performance claims were made and withdrawn before one survived.
Caching an element lookup: worth nothing. Batching a run of rows into a single page insertion — the trick the hand-written reference uses — measured a clean 5% win at fifteen iterations, was written up and published, then retracted twice and reverted entirely.
Here is why. On a machine that is not quiet, a timing sample is not a noisy measurement of one value. It is a clean measurement of one of two, because a run either holds a fast core for its whole duration or it does not. The samples fall into two clusters with nothing in between, and a median over that reports how many fast samples you happened to catch. In the run that produced the 5% claim: one for the reference, four for the old build, nine for the new one. The ranking was scheduling luck.
It does not look noisy while it does this. It looks like a result.
The tell was in the same file the whole time and went unread: the control — the one program guaranteed not to have changed — had drifted to more than twice its own known value in that window. So the tooling grew rules. Read the fast cluster, never the median. Refuse to rank a window whose control has moved. Refuse results that are not from a single run. Measure a byte-identical twin of the control and refuse to certify a window without one; the reader that produced the table above will not print a verdict if you leave the twin out.
Nine candidates for the remaining gap were measured across four sessions. Seven were worth nothing, one was worse than nothing, and one was worth having. Every single one of them was a plausible story about where the time went.
That is what it takes.