September 18, 2026

Wasm, CSS, Robots

Compare Baseline 2021 with Baseline 2026 on webstatus.dev. A lot has changed. CSS became surprisingly more programmable - we even have if() now. Wasm improved just as much, picking up garbage collection and new ways to handle JavaScript values.

My usual stack was still React and a giant pile of components. I started wondering how far Wasm and CSS alone could take me. No virtual DOM, and please, no state managers. Really, it was just a great excuse to write a toy framework.

Spoiler alert: I ended up rediscovering several core ideas my daily tools already rely on. It turns out good architecture is stubborn - it follows you even when you switch programming languages.

Here is the main idea:

  • The application logic lives in Rust and compiles to Wasm.
  • A tiny JavaScript adapter - a shim - connects the Wasm module to the page. So much for skipping JavaScript entirely.
  • State changes project into values that CSS can read directly, like CSS variables. Though some things still require actual DOM updates, as we will see.
  • The runtime handles those page updates. Components just describe how they want to map the state to reality.

We are basically dealing with two different worlds. On the Rust side, everything is strictly typed, neatly owned, and events arrive in perfect order. Over on the JavaScript side, there is a web page and a user happily maximizing entropy. The shim is what connects the two.

A like button with a memory

Let's start with something small. Behold, the LIKE button! Or, to be precise, the 84 KB like button (27 KB compressed with Brotli). It likes. It unlikes. That concludes the entire feature list.

Click it a few times to build up some history, then drag the slider backwards. The runtime applies changes from past states directly to the same button you just clicked. Time travel, basically.

Where do all those kilobytes come from?

The button naturally brings the whole framework along for the ride. For these demos, wasm-pack handles the build - Rust compiles the app and runtime into a .wasm module, wasm-bindgen generates the JavaScript glue, and wasm-opt optimizes the module for size. Throw in the JS shim and the generated CSS, and we get our final bundle.

For all three demos, the bundle size includes the Wasm module, wasm-bindgen glue, the shared shim, generated CSS, and any imported manifests. (The static page HTML and the timeline slider's JavaScript are excluded). A KB means 1,000 bytes, and the Brotli totals represent each file compressed separately at quality 11. We will break down the exact sizes further down the page.

Here is the exact journey from a user click to a CSS value. Let's trace it step by step:

click ──▶ Event::Input { input: Toggle, .. }    appended to the log
                     │
                     ▼ step
              View { liked: true }
                     │
                     ▼ project
        (Slot { Root, "--like-liked" }, 1)
                     │
                     ▼ the diff, then the shim
          :root { --like-liked: 1 }
                     │
                     ▼ stylesheet
     content: if(style(--like-liked: 1): "♥ "; else: "♡ ")

A click becomes an event

The Rust runtime runs a host that manages the component. Its job is to ingest inputs from the outside world and record them as events. A simple button click might look like this:

Event::Input { key: "post:42".into(), input: Input::Toggle }

Rust appends these events in strict sequential order, forming a log. The JavaScript shim acts as the courier, delivering inputs from the DOM, starting with this button:

<button class="like" data-on="click:toggle">like</button>

A click fires a single call across the Wasm boundary. The arguments just identify the toggle action - there is no row index or text payload needed here.

app.dispatch(id, -1, new Uint8Array())

Rust parses the raw input into an event and appends it to the log.

A fold turns events into state

So far, I have built a button with a diary. To make it actually do something visible, we need to collapse that history into state.

We get state by folding the log: going through the events in order and updating the state at each step. This is a classic left fold - just like JavaScript's reduce or Haskell's foldl.

A log and a fold. That is the core idea, and absolutely all the effort I put into naming the framework: LogFold.

The step function takes the current state and a single event, returning the next state. We call this struct a View because it represents the conceptual state of the component. For our like button, that is just a lonely boolean:

pub struct View {
    pub liked: bool,
}

pub fn step(v: View, _: u64, ev: &Ev) -> View {
    match ev {
        Event::Input { input: Input::Toggle, .. } => View { liked: !v.liked },
        _ => v,
    }
}

Hello, Redux 👋. Except here, persisting the history is actually the runtime's job.

That u64 parameter squeezed in the middle is the event's index in the log. Our like button couldn't care less about time, so it ignores it.

Because we store the log, we can casually throw the state away and rebuild it from scratch. (Well, mostly - checkpoints introduce a small catch we will discuss later). Retaining the event history instead of just the current state is affectionately known as event sourcing.

The View in this example only holds the data the like button cares about. There are no massive global stores here. Each view defines its own isolated fold over the exact same log. Multiple views can process the log independently, or we can zip them together to evaluate them in a single pass.

State becomes CSS values

To connect that state back to the page, we declare a component. I tried something new, but ended up right back at familiar components - just like the ones I write in JavaScript.

logfold_core::component! {
    pub mod ui;
    domain Like;
    prefix like;
    inputs { toggle => Toggle }
    root { var liked: int; }
    state View;
    step = step;
    project = project;
}

This macro wires together the inputs, state, and transition functions. The critical piece we need right now is project:

pub fn project(v: &View) -> Projection {
    Projection::new().set(ui::liked.slot(), u8::from(v.liked))
}

// simplified: the real one is sorted and batches writes
pub struct Projection {
    pairs: Vec<(Slot, f64)>,
}

// what ui::liked.slot() is for our button
Slot { target: Target::Root, kind: SlotKind::Var, name: intern("--like-liked") }

project translates the state into a vector of pairs, representing our projection. Each pair is composed of:

  • A slot: a named output target on the page. Here, ui::liked is strongly typed and auto-generated from root { var liked: int; }. A typo in Rust results in a reassuring compiler error.
  • A number: for our boolean state, we just cast it to 1 or 0.

The slot metadata dictates exactly where and how to apply the value. For our button:

  • target is Root, mapped to the <html> element itself. If we needed a specific element, it would be a named target like data-fold="name".
  • kind is Var, indicating the number should become a CSS variable.
  • name is --like-liked: the component's internal prefix merged with the slot's name. We use a prefix because liked is too generic of a word to risk polluting a host page's namespace.

The runtime caches the last projection it emitted and diffs it against the new one. It strictly ships the delta - the patch - to the JavaScript shim. For the like button, the patch is exactly one pair. The component never manually begs for a re-render; the host calculates and applies the patch automatically.

Both the slot identifier and its payload cross the Wasm boundary as raw f64 numbers, sparing us the CPU cycles of serializing strings on every click.

This patch effectively sets --like-liked: 1 in a dynamic stylesheet. By leveraging constructable stylesheets, toggling the button only mutates CSS. The shim manages a virtual :root {} rule and forcefully writes the variable there. No DOM nodes are touched, no classes are toggled, no attributes are updated. The stylesheet is entirely responsible for reacting to the change:

.like {
  scale: calc(1 + var(--like-liked) * .1);
  color: if(style(--like-liked: 1): #d32f2f; else: #222);
}
.like::before {
  content: if(style(--like-liked: 1): "♥ "; else: "♡ ");
}

Arithmetic with calc() for the scale, if() for the rest. The variable is read right where it is needed, with no selector for the state at all. In our tests, its cost was roughly similar to a plain class selector. Rust never sees a heart. It doesn't know what liked looks like, and it shouldn't.

There is a browser-support limit: if() is Chromium-only today. In other current browsers the demo falls back to a style query on the same variable, which was a bit slower in our tests:

@container style(--like-liked: 1) {
  .like { color: #d32f2f; }
  .like::before { content: "♥ "; }
}
What actually crosses, and where it lands

Every change is just five numbers: which element, which row, what kind, which name, and what value. A click on the like button sends exactly this:

[0, -1, 0, 1, 1]
// element 0 is "root", -1 is "no row", kind 0 is a variable, name 1 is "--like-liked", value 1 is "on"

The shim fetches each name once and caches it. At startup, it creates a stylesheet with a single empty rule. Root variables are injected straight into that rule:

const sheet = new CSSStyleSheet()
sheet.replaceSync(':root {}')
document.adoptedStyleSheets.push(sheet)

sheet.cssRules[0].style.setProperty('--like-liked', 1)

This is a direct write to CSS, completely bypassing the document. In our measurements, it was roughly as cheap as toggling a CSS class. The generated stylesheet also registers the variable, giving it a strict type and a default:

@property --like-liked { syntax: "<integer>"; inherits: true; initial-value: 0; }

A named target can receive variables through a rule on its class, meaning the shim does not even need to look up the element. Repeated rows just carry their values on the elements themselves.

The history panel still updates its own DOM after a click. Browsers without constructable stylesheets also use a fallback that writes root variables directly to <html>'s inline style. So, the like action leaves the DOM alone when it uses a constructable stylesheet - the history panel and the fallback still change it.

Ultimately, Rust's view of the page is just a flat vector of numeric slot-value pairs. The semantic HTML structure and visual styling are rightfully confined to the browser.

Challenges

Even a simple like button raised two questions: how much does crossing between Rust and JavaScript actually cost, and why is the bundle so large?

The border

Crossing the border between those two worlds sounds expensive. In our measurements, however, small numeric calls were cheap - it is converting strings that takes real work.

In the Rust world, ordinary data structures live in Wasm's linear memory, which is just a big array of bytes. JavaScript can peer into those bytes through typed-array views. Meanwhile, JavaScript objects (including DOM nodes) live out in JavaScript-land. Rust can hold handles to them, but their contents do not magically cross over to become Rust data. Here is how that border affects our framework:

  • Numbers need no string encoding or object serialization. This suits our numeric patches perfectly.
  • Converting between a Rust String and a JavaScript string copies and re-encodes the contents across the border - Rust uses UTF-8, while JavaScript exposes UTF-16 code units. Keeping a reference to an existing JS string avoids that conversion.
  • Wasm can receive a host value as an externref. With wasm-bindgen, Rust stores a handle to that value, but calling its JavaScript methods still involves a trip back across the border.
  • Calling the DOM from Rust does not bypass the browser's native DOM, style, or layout work. Rust does not get a secret, faster DOM in its own world.
A detour: JS string builtins and Rust

Wasm 3.0 brought JS String Builtins. Holding a JavaScript string as an externref was already possible. The new part is working on it through functions from the reserved wasm:js-string namespace: length, concat, substring, equals. The engine recognizes these imports and can optimize them without an application-written JavaScript wrapper. Reading a JS string's length needs no conversion to UTF-8. Creating or concatenating strings can still allocate.

WebAssembly.instantiateStreaming(fetch('app.wasm'), imports, {
  builtins: ['js-string'],
  importedStringConstants: 's',
})
(global $liked (import "s" "liked") externref)
(func $equals (import "wasm:js-string" "equals")
  (param externref externref) (result i32))

The second option, importedStringConstants, is particularly neat. The engine reads the import name and supplies it as a JavaScript string. The literal still appears in the module's import metadata, but it does not need a UTF-8 copy in linear memory.

Rust can already keep a JS string without copying its contents through js_sys::JsString. Rust holds a numeric handle, and the actual reference lives in a table. Those handles can live in structs and vectors. A Vec<JsString> is perfectly legal; a Vec of bare Wasm references is a different matter.

Our current pipeline does not use the new builtins. With wasm-bindgen 0.2.128 and the corresponding js-sys 0.3.105 release, the ordinary JsString bindings call JavaScript properties and methods. Using wasm:js-string imports would take extra integration. The handles themselves do not prevent this: Wasm can look up a reference in its table and pass it to a builtin. I had only meant to build a like button.

For the time being, LogFold takes the stubborn route: the log strictly keeps Rust-owned text, which intentionally allows the core logic to run outside a JavaScript host too. Variable names are fetched and cached once by the shim, so subsequent patches only need to carry numeric IDs. When a user types text, it enters the log as UTF-8 bytes. If a patch needs to display text, it just sends the event's numerical index, prompting the shim to request the JavaScript string just in time. This string allocation still costs us, but it is a deliberate architectural tradeoff.

The bundle

Then there is the glaring issue of bundle size. Compiling Rust to Wasm inevitably drags along standard library baggage. Even though we avoid shipping a garbage collector, we still have to include a memory allocator, panic formatting support, and standard collections. Here is the breakdown of the like button runtime (counting the files listed above, compressed with Brotli at maximum quality):

the Wasm module         19.01 KB
the JS shim              5.79 KB
wasm-bindgen glue        2.27 KB
the generated CSS        0.10 KB
                        --------
                        27.17 KB

That is 27 KB compressed (84 KB uncompressed) for a like button I could write in a few lines of JavaScript. When I ran twiggy profiles on earlier uncompressed Wasm builds, the size mostly came from the host and exports, framework machinery, standard collections, panic formatting, and the memory allocator. (Keep in mind those profiles describe the raw Wasm module, not the compressed totals above).

The actual business logic for the like button is a tiny fraction of its own bundle. Most of the weight is the infrastructure we brought along just to flip one flag. No matter how you slice it, it is a lot of code for a heart.

Naturally, I tried a few ways to trim that overhead:

  • Swapped the allocator. Rust's default Wasm allocator (dlmalloc) was hogging roughly 8 KB of uncompressed space. Our framework mostly allocates small, short-lived vectors, so swapping to the leaner talc allocator cut that footprint in half.
  • Jettisoned the standard sort. Shockingly, a single innocent call to Rust's standard sort bloated the uncompressed Wasm module by 17 KB. A rudimentary, hand-written merge sort consumes a fraction of that.
  • Stripped out formatting. The assert_eq! macro feels harmless, but if it triggers, it faithfully formats and prints both sides of the equation. This forces the compiler to statically link the heavy formatting traits. Switching to a primitive assert! with a static string shaved off precious kilobytes.
  • Dynamic trait objects for lists. The heavy machinery for managing dynamic DOM lists is hidden behind a trait object. If a component (like our like button) does not use lists, the linker aggressively strips that dead code out.
  • The dark arts of Wasm optimization. We enabled Link-Time Optimization (lto = true), restricted compilation to a single codegen unit, set panic = "abort", and ran wasm-opt -Oz as a post-build step. Hilariously, setting Rust's opt-level = "z" produced a smaller binary but a larger Brotli-compressed file, so we reverted to the default opt-level.
The build without wasm-bindgen

There is also a build without wasm-bindgen at all: plain extern "C" functions and numbers. The current raw Wasm module is 52.18 KB uncompressed and 17.85 KB Brotli, versus 54.74 KB and 19.01 KB for the bindgen module. It also needs no generated bindgen JS, which is another 2.27 KB Brotli in the normal build. These are module and glue comparisons; the raw path has its own loader, so they are not whole-page totals.

The todo list

The like button was easy because it has a fixed structure. A dynamically growing list is a different beast entirely. CSS is fantastic at restyling, moving, or hiding existing elements, but it cannot conjure new DOM elements out of thin air.

So, naturally, I built a todo list. If you are writing a toy framework, you have traditions to respect.

Type something, press Enter, tick a box. The slider under the list works exactly as before: drag it backwards and the list travels back in time, titles and all.

Please don't feed the todo list evil HTML

The todo rows properly treat your input as plain text. However, I accidentally missed the history panel, which still dangerously inserts it as HTML. Please keep your groceries free of evil scripts until I fix that bug. Yes, I realize my polite request is doing absolutely zero security work here. :)

To pull this off, we need three major additions to the framework: text inputs, dynamic new rows, and native checkbox state.

Text

A todo title can be literally anything the user types. The stylesheet cannot possibly know it in advance, and right now, our projections exclusively deal in numbers.

CSS can theoretically display strings pulled from an attribute, but CSS-generated content does not allow for reliable text selection or copying. I want to be able to highlight and copy my todo list, so we need a real, standard DOM text node.

An input event can carry text. Adding text to its declaration tells the runtime host to grab the input field's value as raw bytes:

inputs { add: text => Add, toggle: index => Toggle }

Press Enter, and an Add("write another toy framework, but now in C++") event is appended to our log. Our projection still only needs to contain numbers: the title slot simply stores the log position of that specific event.

// item.id is where its Add event sits in the log
.set(ui::item::title.at(row), item.id as f64)

So the resulting patch just says "row 0, title, event 7". The JavaScript shim then asks Rust for the text of event 7 and writes it directly into the row as a real DOM text node:

row.querySelector('[data-text="title"]').textContent = app.text(7)

The text crosses the border into Rust when it is typed, and crosses back into JavaScript when a row needs to display it. The patches carry only its event index. Because the host safely retains the text in the log, historical views can perfectly display the original titles too.

New rows

Next, the shim needs to actually render the new item. The host page provides a <template> containing the markup for exactly one row:

<ul>
  <template data-fold="item">
    <li class="item">
      <label>
        <input class="check" type="checkbox" data-checked="done" data-on="click:toggle">
        <span class="title" data-text="title"></span>
      </label>
    </li>
  </template>
</ul>

Rust describes the rows as a family of slots:

family item { class present; checked done; attr n: int; text title; }

There is no separate "create a row" command. Rust projects numbers: "row 3 is present, its number is 4, its title is event 12". When a patch names a row the shim has never seen, the shim makes one:

  • It collects everything the patch says about the new row: classes, attributes, the title.
  • It renders the row from the template as a string, with those first values already in it.
  • All new rows of one patch go in with a single insertAdjacentHTML. A thousand rows is one parse, not a thousand clones.
  • It remembers the element under its row number, so later writes go straight to it.

We add the title as text after inserting the row, so a todo called <script> stays a todo.

In this todo list, rows are never removed. A row the state no longer mentions loses its present class, and the stylesheet hides it.

The checkbox

The checkbox also needs a DOM update. Its checked state is a native element property that CSS cannot set. We give it a slot of its own:

family item { class present; checked done; attr n: int; text title; }

The shim writes checked on the input marked data-checked="done", and CSS reads that state through :checked:

.item:has(.check:checked) .title { text-decoration: line-through; }

The click also carries the row's index, as specified by toggle: index in the declaration. Rust uses it to toggle the corresponding item.

A confession about keys

Our rows are identified by position. A click says "toggle row 2", and Rust flips the third item: indices start at zero. This works for our app because we never remove an item or insert one in the middle.

If you click while viewing the past, the action still goes at the end of the log and the page jumps back to the present. It does not create a new branch of history. If we allowed removals or insertions in the middle, row 2 in the past could be a different todo from row 2 now, and we could toggle the wrong one. The fix is the usual one: give every row a stable key. We even have one for free, the place of its Add event in the log. Hello, React keys.

A row that knows its number

Every todo has a number in front of it. I thought CSS could handle that for me, but sibling-index() made appending a row slower in our Chrome test. In the end, Rust supplies the number as an attribute and CSS displays it with attr(). The dull solution won.

How CSS lost the counting job

The first version used a CSS counter. It works, but the numbering depends on what came before. A change near the top can make the browser recalculate the numbers below it.

Then sibling-index() reached Baseline: a function that tells an element which child it is. It looked like the perfect fit. Each row knows its own number, no chain.

/* minus one, because the <template> is a sibling too */
.item::before { counter-reset: n calc(sibling-index() - 1); content: counter(n); }

So we measured it in Chrome. On a list of ten thousand rows, appending one more got about four times slower than with the old counter. So much for my assumption that each row could handle this independently. The newest CSS function in this post came last in this test.

In the end, Rust supplies the row number too. The shim writes it to an attribute when the row appears, and CSS displays it with attr().

family item { class present; checked done; attr n: int; text title; }
.item::before { content: attr(data-n); }

This adds one write when a row appears and no further numbering updates for our append-only list. In this test, the cost was reasonably close to having no numbers at all.

And that is our second app. Hooray, the TODO list! Or, to be precise, the 92 KB todo runtime. 30 KB brotlied. Using the same counting rules, that is about 2.4 KB more than the like runtime before rounding. It adds. It ticks. It cannot remove an item, it cannot edit one, and a reload loses everything, because the log lives in memory. That is the whole feature list.

How much history can we keep?

The first version kept every event forever. The runtime could still reach all that memory. I had just told it to remember everything. A memory leak with excellent record keeping.

Keeping up with new events is cheap: we apply each one to the current state once. Going back in time takes more work. We do not want to read the whole log from the start every time we move the slider.

That is where checkpoints help. We save the state after k events, then continue from there with the remaining events and their original indices. Because the fold is deterministic, we get the same result:

fold(log) == fold_from(state_at(k), log[k..])   // for any k

We can also throw away the events before a checkpoint. But then we have to keep that checkpoint. We need it and the remaining log to rebuild the state. The slider cannot visit the forgotten past, and a new fold cannot read events we no longer have. Time travel has a retention policy.

Text needs special treatment too. In our todo list, a title refers to the event that supplied its text. Before deleting such an event, we move the text into a separate archive. That archive can keep growing, as can the app's state and the rows we hide rather than remove. This limits the event history, but other memory use can still grow.

What if there are many folds?

We zip them all together before we go. zip runs two folds over the same events in one pass and keeps a pair of states, so a checkpoint is always the state of one fold. Views derived with map need no separate checkpoint: they are functions of that state.

Time travel between checkpoints

Checkpoints live in a B-tree, ordered by event position. To show a past state, we find the nearest checkpoint at or before that position, then replay the remaining events. This is how the slider shows the past.

A small Wasm lesson. In an earlier build, checkpoint cleanup pulled extra tree-removal code into the Wasm module through a single call to BTreeMap::remove. To shrink the module, we had to stop calling that method. Instead of removing old checkpoints, we now rebuild the tree from the ones we keep. Same retained checkpoints, less machinery to ship.

Brunhilda

For something more complicated, I borrowed a problem from my daily life. I have a robot vacuum cleaner lurking in my apartment, and she tries to sweep me up whenever I am in her way. I am fond of her anyway. She has a strong character, so she got a strong name: Brunhilda.

The next app simulates this arrangement: Brunhilda sweeping the apartment, the furniture standing in her way, and me walking around.

Press Start. You are the blue dot: click inside the room, then use the arrow keys to move. And yes, there is a slider.

Time

To make the app work we need one more concept: time. A like button doesn't care what time it is. A robot does.

Rust never reads a clock. Time arrives as another event in the log, Event::Tick { ms: 250 }, and the page owns the clock. While the state says "running", a small loop on the page advances the simulation a few times a second:

if (running) host.frame(1000 / fps)

frame(250) advances virtual time by 250 ms. The log stores the total elapsed time in Tick.ms: 250, 500, 750, and so on. Frames also record what the simulated sensors report.

This is why the slider works on Brunhilda too. Replaying the commands, sensor reports and ticks in order gives us the same robot state. Even the speed is state: faster and slower are ordinary inputs, like a click on the like button.

A brain, a studio, a world

To make Brunhilda move, we wrote a small crate for her brain. It is a fold, like everything else here: events in, a state out. Her events are commands (start, dock, stop), sensor reports (a bump, "the human is at this cell") and ticks. That is her whole vocabulary.

The same framework builds the rest: the controls of the studio, and the simulated world - the room, the furniture, me.

The separation is similar to the one between Rust and the page. Rust describes what should be visible as data; the browser host applies it.

Brunhilda's brain works the same way. Events come in; a heading or an emergency-stop request comes out as data. The host acts on it. Her brain knows nothing about the page or the simulation. She lives in the Matrix!

In principle, a host for real wheels could keep the same brain crate, as long as it uses the same sensor and movement model. I have not tested that. My furniture has suffered enough.

I worried that this boundary would slow things down, but it also helps organize the code. The component says what should happen, and the host makes it happen. That works for both a page and a simulated robot.

Effects

Her emergency stop is an effect. The full design needs its own post, but the basic idea is simple. A fold never performs the effect itself. It only says what it wants, as a value: "this request should be in flight", "the motors should be stopped".

The host compares that with what it has already started and records a Started event before starting the effect. If the effect has an answer, that comes back as another event. The emergency stop is fire-and-forget; it has no answer to wait for.

Replay uses the recorded events to rebuild state. It does not send the request again. Dragging a slider should not accidentally order a second pizza.

This was also where testing the behavior mattered. I built the first prototype quickly with my LLM agents, then spent much longer fixing bugs and refining it. Along the way, I used a bundle analyzer, made the benchmarks more consistent and wrote a fuzzer.

The fuzzer tests a networked like-button model against a simulated server, trying to break the effects logic. That model still needs code to send real network requests. I will leave that for another post.

And that is our third app. Hooray, the ROBOT! Or, to be precise, the 144 KB robot runtime. 46 KB brotlied, with the same counting rules and its imported manifest included. She cleans. She docks. She stops when told. With the naive policy she still attacks me. That is the whole feature list.

So, how fast is it?

I also wanted to compare the runtime with vanilla JS and React. These tests cover a few table operations, only a small part of what a full framework does.

I used js-framework-benchmark, which creates, updates, swaps and clears table rows.

The benchmark expects real text in the table. So LogFold uses a separate adapter for this benchmark in place of its usual shim. Rust still produces numeric patches; the adapter writes text and manages rows. The results below measure Rust and this adapter together. The demos use CSS differently, so these numbers do not tell us how fast that part is.

I ran the benchmark's own driver on my laptop.1 These are median times in milliseconds, alongside the vanilla JS reference and React 19.2 hooks entry:

js-framework-benchmark, its own driver, one laptop. LogFold uses a benchmark-specific DOM adapter. Medians, milliseconds. Lower is better.
LogFoldvanilla JSReact 19.2 hooks
create 1,000 rows343138
update every 10th row191724
select a row5610
swap two rows2020156
remove a row181720
create 10,000 rows355319566
clear151527

The same run also measured bundle size, first paint and memory. These saved results predate the updated demos above. The driver counts implementation files, including the benchmark adapter, but excludes shared /css assets and HTTP headers. Compression here means Brotli.

Same benchmark, same run. Lower is better.
LogFoldvanilla JSReact 19.2 hooks
bundle, uncompressed, KB9111190
bundle, compressed, KB312.551
first paint, ms5257288
memory after 1,000 rows, MiB4.01.94.4

LogFold finishes close to vanilla JS in these tests. Rust computes the changes and the adapter applies them directly to the DOM. The JavaScript side has no tree to compare, although the browser still has style, layout and paint work to do.

The bundle is heavier: about twelve times vanilla's compressed size, including the Rust support code discussed earlier. The benchmark also reports roughly twice vanilla's memory figure. I have not separated the cost of the log from the state, checkpoints and other allocations, so I do not yet know how much each contributes.

The fine print
  • One laptop, one session. Results will vary between machines and runs. CPU medians come from 15 samples, or 25 for row selection. The saved memory and first-paint figures each have one observation.
  • The driver can apply CPU throttling to individual tests. The saved results do not identify the exact benchmark revision and throttling settings, which limits how closely another run can reproduce them.
  • The DOM adapter is used only by the benchmark and is saved alongside the raw results for anyone who wants to inspect the entry or run it again.
  • React and its ecosystem support much more than this toy. That makes the comparison narrow; it does not explain any particular timing in the table.

Wrapping up

This is still a long way from a full-featured framework. Three demos and a robot with questionable manners do not quite get us there. But the results are promising.

The approach turned out to be less crazy than I expected. Performance looks good in the tests so far, and the bundle sizes seem acceptable for the experiments I want to build next. I say this after spending several paragraphs making fun of my own like button.

I also found myself enjoying writing components in Rust. I think I will keep playing with it and see how it goes.

Hope you enjoyed the story :)

1. Machine: 16-inch MacBook Pro (2021), Apple M1 Max, 32 GB memory; macOS 26.5.2 (25F84). Browser: Google Chrome 152.0.7977.83 (Official Build), arm64.