Blog

MoonZoon: Foundations (2021)

moonzoon
rust
wasm
signals
boon

Signals over hooks, WASM size battles, and fullstack Rust lessons from building MoonZoon.

MoonZoon logo

In early 2021, I started writing a series of technical articles documenting the development of MoonZoon - a Rust fullstack framework where frontend and backend share one language, one type system, and work together from the start.

This post summarizes the key technical insights from those articles.

The Problem: Web Development Complexity

Modern web development has become unnecessarily complex. New developers must learn HTML, CSS, JavaScript, JSON, Git, DNS, HTTP, REST, WebSockets, SQL, and authentication - a daunting prerequisite list. Then comes the decision fatigue: React vs. Vue, MongoDB vs. PostgreSQL, serverless vs. VPS.

The deeper problem? As I wrote in Cure for Web Development:

“It works, until you try to do the first major refactor - you’ll feel like you are going through a minefield.”

HTML and CSS, designed for simple web pages decades ago, cannot adequately serve modern applications.

Cure for Web Development

The Elm Architecture vs Components

One of the most popular articles in the series explored frontend architecture patterns. MoonZoon Dev News 2 compared The Elm Architecture (TEA) with component-based approaches.

TEA’s strength is that Model (the global state) provides “a single source of truth for all your data” - you can inspect the complete app state at any moment.

TEA’s weakness is code fragmentation. Applications consist of “5 big isolated trees - Model, init, Msg, update and view” where modules spread across all of them. Inter-component communication becomes challenging: “you can’t create custom subscriptions.”

Component-based approaches attempt to solve this but introduce their own problems: excessive modularity, abstractions at incorrect levels, and heavy reliance on JavaScript compilation layers. Critically, “almost nobody really knows how to work with them effectively.”

MoonZoon’s Zoon took a hybrid approach using decorator-based blocks:

#[s_var]  // state variable
fn counter() -> SVar<i32> { 0 }

#[update]  // update handlers
fn increment() { counter().update(|c| c + 1); }

#[cache]  // computed/derived values
fn doubled() -> i32 { counter().inner() * 2 }

#[cmp]  // component rendering
fn root() -> Cmp {
    col![
        button![button::on_press(increment), "+"],
        text![counter()]
    ]
}

Note: This was an early API design influenced by my work on Seed, a Rust frontend framework I maintained before MoonZoon. The current Zoon API evolved differently, with fewer macros and a builder-pattern approach.

This naturally separates TEA’s trees into standalone blocks while providing local component state through cmp_var - without polluting global state.

Rust Hooks > React Hooks

The most popular article in the series, MoonZoon Dev News 3, tackled a fundamental problem with React-style hooks.

React’s hook system relies on call order to determine identity. The framework assigns unique IDs using a counter. Consider two render cycles where a conditional changes which hooks are called:

Render 1: mike() -> id=1, layla_rose() -> id=2
Render 2: mike() -> id=1, amber()      -> id=2  // wrong!

When conditional logic changes which functions execute, call IDs become unstable. State incorrectly associates with different components across renders. This is why React requires the “rules of hooks” - no hooks in conditionals.

The Rust solution: Rather than relying on indices alone, MoonZoon leveraged Rust’s #[track_caller] attribute combined with Location::caller(). This identifies hook calls by their source code location:

Key = (call_index, file_location)

This approach proved more robust than React’s index-based system, preventing data mismatches even with conditional rendering.

Eventually, MoonZoon moved beyond hooks entirely to a signals-based reactive system. Signals provide reactive data binding without requiring a Virtual DOM, eliminating the need for call counters, location tracking overhead, and rules-of-hooks compliance checks.

The performance difference was dramatic. Using the Dominator library under the hood, Zoon could update thousands of rows basically immediately, while other Rust frameworks needed to re-render entire pages.

Fullstack Architecture

The architecture looked like this:

Three-Tier Structure

MoonZoon applications are organized into three crates:

  • Shared - Serializable types for frontend-backend communication (using serde_lite to reduce Wasm file size)
  • Moon (backend) - Actix-based server with virtual actors, inspired by Orleans, Phoenix, and Meteor
  • Zoon (frontend) - Reactive UI with signals, drawing from elm-ui, Dominator, and Svelte

Chat example

Live-Reload via SSE

As detailed in MoonZoon Dev News 1, the dev server uses Server-Sent Events (not WebSockets) for live-reload. The frontend receives two event types:

  • Reload Events - Trigger page refresh after file changes
  • Backend Build ID Events - Auto-reload when backend restarts

HTTPS runs during development to mirror production, with HTTP/2 eliminating SSE limitations.

Warp to Actix Migration

MoonZoon Dev News 4 documented the migration from Warp to Actix. I wanted speed, async capabilities, and better Tokio integration. A key simplification: the main function became async, eliminating the need for separate init functions.

Cleaner Error Handling

The mzoon CLI adopted a two-library approach:

  • anyhow - Enables early returns via ? without manual error mapping
  • fehler - Eliminates verbose Ok(()) wrapping through the #[throws] macro

Virtual Actors for Sessions

MoonZoon Dev News 5 introduced virtual actors: “sessions are virtual actors managed by the Moon. Each SessionActor represents a live connection between the frontend (Zoon) and backend (Moon).” This enables broadcasting to all clients or targeting individual sessions via correlation IDs.

Hard-Won Lessons

Building a fullstack Rust/WASM framework surfaced challenges that aren’t obvious until you hit them.

WASM File Size: Death by Dependencies

One of the most surprising findings was how dramatically dependencies affect WASM bundle size. Using a simple counter example:

ConfigurationOptimizedBrotli
Base app33 KB14 KB
+ 1.2.to_string()52 KB21 KB
+ url crate338 KB113 KB
+ url + regex crates928 KB236 KB

A single float formatting call adds ~19 KB. The regex crate alone adds ~590 KB due to Unicode tables. These numbers shaped MoonZoon’s dependency choices:

  • serde_lite instead of full serde
  • js_sys::RegExp instead of the regex crate
  • web_sys::Url instead of the url crate
  • ufmt as an alternative to std::fmt

The Cargo.toml optimization settings that worked best:

[profile.release]
lto = true
codegen-units = 1
opt-level = 3  # or 's' for size

[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O4']  # or '-Os' for size

Compression Pipeline Gotchas

MoonZoon’s CLI generates both Gzip and Brotli variants of all assets. This uncovered several issues:

  • The async-compression bug — The crate produced 9 KB output instead of the expected 16 KB. Fix: always call .flush() after .write_all().
  • Browser quirks — Firefox only supports Brotli over HTTPS, while Chrome accepts both on HTTP. Solution: serve both formats and select based on Accept-Encoding.
  • HTTP/2 necessity — Browsers limit HTTP/1.x to 6 concurrent connections per domain. SSE holds a persistent connection, so you quickly run out. HTTP/2 multiplexes over a single connection — but browsers only support it over HTTPS. Result: HTTPS even in development.

Build Tooling Challenges

The mzoon CLI faced several unexpected hurdles:

  • File watcher debouncing — The notify crate (5.0.0-pre) lacked built-in debouncing. Custom implementation: abort-and-reset pattern where new file changes cancel pending rebuilds.
  • Directory traversal — Rust lacks tail-call optimization, so recursive walking risks stack overflow. Solution: futures::stream::unfold() for iterative, non-blocking traversal.
  • Macro conflicts — Combining async-trait with fehler’s #[throws] proved “deadly for the compiler.” Fix: explicit Result<()> annotations.
  • wasm-pack auto-install — The CLI auto-downloads wasm-pack from GitHub. Strict platform matching broke on Heroku. Fix: relaxed matching — any Linux can run the musl binary.

SSE Reliability

Server-Sent Events for live-reload seemed simple but had edge cases:

  • 10-second ping interval to detect stale connections
  • Firefox permanently closes SSE after backend restart - requiring the ReconnectingEventSource library for automatic reconnection
  • Certificate serial numbers must be unique per generation, or Firefox throws SEC_ERROR_REUSED_ISSUER_AND_SERIAL

What Came Next

Development continued for another 4.5 years after these articles. Routing, animations, Web Workers, Tauri desktop apps, and GPU graphics with wgpu all followed. Read MoonZoon: 2021–2025 for the full story.

Many ideas from MoonZoon eventually carried forward into Boon. The reactive signals architecture evolved into Boon’s dataflow model. The frustration with HTML/CSS abstraction led to new rendering approaches. For the ideas that shaped it, read The Boon of Dataflow.


These articles were originally published on dev.to between February and July 2021:

  1. Cure for Web Development - February 2021
  2. MoonZoon Dev News 1: CLI, Build Pipeline, Live-Reload, HTTPS - March 2021
  3. MoonZoon Dev News 2: Live Demo, Zoon, Examples, Architectures - April 2021
  4. MoonZoon Dev News 3: Signals, React-like Hooks, Optimizations - May 2021
  5. MoonZoon Dev News 4: Actix, Async CLI, Error Handling, Wasm-pack Installer - June 2021
  6. MoonZoon Dev News 5: Chat Example, MoonZoon Cloud - July 2021

Press & interviews: