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 my 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 my 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 my 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 my 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. I 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 I 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 formally describes the rows as a family of slots:

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

Notice there is no separate "create a row" command. Rust purely projects numeric facts: "row 3 is present, its number is 4, its title is event 12". When a patch targets a row index the shim has never seen, the shim simply creates it:

  • It eagerly collects everything the patch dictates about the new row (classes, attributes, the title).
  • It renders the row from the HTML template into a raw string, with those initial values interpolated.
  • All new rows for a given patch are injected via a single insertAdjacentHTML call. Adding a thousand rows requires a single DOM parse, not a thousand slow element clones.
  • It memoizes the element reference by its row number, so subsequent writes go straight to the target.

We explicitly append the title as a text node after inserting the raw HTML row, ensuring a todo titled <script> safely remains just a todo.

In this particular todo list, rows are never actually removed from the DOM. If the state no longer mentions a row, it merely loses its present class, and the stylesheet quietly hides it.

The checkbox

The native checkbox requires a bit of special treatment. Its checked state is a native element property that CSS simply cannot override. So, we grant it a dedicated slot of its own:

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

The JavaScript shim applies checked directly to the input marked data-checked="done", and CSS reacts to that state using the native :checked pseudo-class:

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

When clicked, the event payload also carries the row's numeric index, precisely as requested by toggle: index in the component macro. Rust then uses this index to correctly identify and toggle the item in its state.

A confession about keys

Our rows are currently identified purely by their position. A click effectively says "toggle row 2", and Rust blindly flips the third item (since indices start at zero). This approach survives in our toy app only because we never actually remove an item or insert one in the middle.

If you click a checkbox while time-traveling in the past, the action is still appended to the end of the log and the page rubber-bands back to the present. It does not create an alternate timeline branch. If we allowed removals or middle insertions, "row 2" in the past could be an entirely different todo from "row 2" in the present, leading us to toggle the wrong item. The robust fix is the classic one: give every row a stable, unique key. Conveniently, we already have one for free - the position of its initial Add event in the log. Hello again, React keys.

A row that knows its number

Every single todo has a number in front of it. Originally, I figured CSS could easily handle the counting for me, but it turned out that sibling-index() actually made appending a new row slower in my Chrome benchmarks. In the end, Rust explicitly supplies the number as an attribute, and CSS just displays it using attr(). The boring, dull solution won out.

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 I 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 concludes our second app. Behold, the TODO list! Or, to be precise, the 92 KB todo runtime (30 KB compressed with Brotli). Using the same counting rules as before, that is roughly 2.4 KB larger than our lonely like button runtime. It adds items. It ticks checkboxes. It cannot remove an item, it cannot edit an item, and refreshing the page wipes everything out because the event log lives exclusively in volatile memory. That concludes the entire 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, I 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 slightly more complex, I borrowed a problem from my daily life. I have a robot vacuum cleaner lurking in my apartment, and she relentlessly 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 exact arrangement: Brunhilda sweeping the apartment, the furniture standing in her way, and me helplessly 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 time travel slider.

Time

To make this simulation work, I needed one more concept: time. A like button doesn't care what time it is. A robot does.

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

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

frame(250) advances virtual time by 250 ms. The log strictly stores the total elapsed time in Tick.ms: 250, 500, 750, and so on. These ticks also record whatever the simulated sensors report at that exact moment.

This is exactly why the time travel slider flawlessly works on Brunhilda too. Replaying the commands, sensor reports, and ticks in order gives us the exact same robot state. Even the simulation speed is just state: "faster" and "slower" are ordinary inputs, identical to a click on the like button.

A brain, a studio, a world

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

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

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

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

In principle, a host for real physical wheels could run the exact 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 briefly worried that this strict boundary would slow things down, but it naturally helps organize the code. The component declares what should happen, and the host makes it happen. That architecture scales for both a web page and a simulated robot.

Effects

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

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

Replaying the log uses the previously recorded events to seamlessly rebuild state. It does not execute the request again. Dragging a time travel 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 concludes our third app. Behold, the ROBOT! Or, to be precise, the 144 KB robot runtime (46 KB Brotlied), including its imported manifest. She cleans. She docks. She stops when told. With the naive pathfinding policy, she still aggressively attacks me. That is the whole feature list.

So, how fast is it?

I also wanted to see how the runtime stacked up against vanilla JS and React. Note that these tests only cover a few raw table operations - just a tiny slice of what a full-fledged framework actually does.

I used the js-framework-benchmark suite, which measures creating, updating, swapping, and clearing table rows.

The benchmark naturally expects real text in the table. Because of this, LogFold uses a separate, specialized DOM adapter for this benchmark instead of its usual CSS-heavy shim. Rust still natively produces numeric patches; the adapter just translates those to text and manages the DOM rows. The results below measure Rust and this adapter working together. Since the demos use CSS quite differently, these numbers do not reflect the CSS performance.

I ran the benchmark's own driver on my laptop.1 These are the median times in milliseconds, placed alongside the vanilla JS reference and the 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 exact same run also measured bundle size, first paint, and memory. These saved results predate the updated demos shown above. The driver only 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 holds its own surprisingly well in this narrow test. It sits in a very similar tier for basic table operations, though it lags a bit on creating 10k rows. The 31 KB bundle is impressively light for a framework carrying over the Wasm bundle. And the 4 MiB memory peak on 1,000 rows suggests it remains incredibly frugal when nothing forces the browser's hand, despite the inherent memory overhead of keeping an event log.

Memory was also stable across iterations for the Rust heap, while JS memory showed the exact same GC peaks and troughs as the mainstream frameworks. The host JS simply drops references to old log values whenever it creates a checkpoint. The V8 garbage collector seemed perfectly happy to clean up those values. I saw no obvious memory leak beyond the intentionally retained history itself.

There are, of course, limits. A massive array of state will eventually trigger painful garbage collection sweeps for all elements. Pushing 10,000 arrays of 10,000 numbers in JS will eventually exhaust memory. Wasm linear memory can bloat too. I fully expect these upper bounds to matter significantly more in large applications. But for a toy, it is more than enough.

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.

The end

This toy framework met the absurd goals I started out with. CSS absolutely can handle state, and a fold of events can reliably build that state without JS. It works smoothly across three demos and runs fast enough to compete on a small table.

Will it power my next real app? Maybe. With a good agent setup, it's not quite difficult to adjust a home-built solution for almost every need. However, to make the framework suitable for every production case, much more work is needed. So I can teach it to solve my task, however it will never compete with a real framework.

But it was wildly fun.

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.