Project Code:https://github.com/euv-dev/euv
WebAssembly (WASM) is a binary instruction format that runs in modern web browsers at near-native speed. euv is built from the ground up to target WASM, compiling Rust code into efficient browser-executable modules. This article takes a deep dive into how euv uses WebAssembly under the hood, covering the compilation pipeline, memory management, performance characteristics, and the runtime architecture that makes it all work.
Table of Contents
- The WASM Compilation Pipeline
- Memory Management in WASM
- How euv Renders via WASM
- Performance Characteristics
- The Runtime Architecture
- Working with JavaScript from WASM
- Conclusion
The WASM Compilation Pipeline
The journey from Rust source code to a running euv application in the browser involves several steps:
Rust Source (.rs)
↓ rustc (with wasm32-unknown-unknown target)
WASM Binary (.wasm)
↓ wasm-bindgen
JavaScript Glue (.js) + WASM Binary
↓ Browser loads via ES module
Running Application
Step 1: Project Configuration
Every euv project starts with the proper Cargo.toml configuration:
[package]
edition = "2024"
[dependencies]
euv = "*"
lombok-macros = "*"
[lib]
crate-type = ["cdylib", "rlib"]
The crate-type = ["cdylib"] entry tells the Rust compiler to produce a dynamic library suitable for WASM. The rlib type allows the crate to also be used as a Rust library (for testing, for example).
Step 2: The Application Entry Point
use euv::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn main() {
console_error_panic_hook::set_once();
mount("#app", app);
}
The #[wasm_bindgen] attribute generates JavaScript glue code for the main function. The console_error_panic_hook::set_once() call ensures that Rust panics are logged to the browser console — essential for debugging WASM applications.
Step 3: Building with wasm-pack
wasm-pack build --target web --out-dir www/pkg
The --target web flag produces output that can be loaded directly in the browser using ES modules. After a successful build, the pkg/ directory contains:
-
my_app_bg.wasm— the compiled WebAssembly binary -
my_app.js— the JavaScript glue code -
package.json— metadata for the package
Step 4: Loading in the Browser
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>euv app</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import init, { main } from './pkg/euv_example.js';
await init();
main();
</script>
</body>
</html>
The init() function loads and instantiates the WASM module, and main() starts the euv application.
Step 5: Development with euv CLI
For a smoother development experience, euv provides a CLI tool:
cargo install euv-cli
euv run --crate-path ./example --www-dir ./www --port 80
The CLI handles building, file watching, hot reloading, and serving. Build modes are controlled via flags:
# Development build (debug info enabled, optimizations disabled)
euv run --crate-path ./example --www-dir ./www --port 80 --dev
# Release build (optimizations enabled, debug info disabled)
euv run --crate-path ./example --www-dir ./www --port 80 --release
# Profiling build (optimizations enabled, debug info enabled)
euv run --crate-path ./example --www-dir ./www --port 80 --profiling
Memory Management in WASM
WebAssembly has a fundamentally different memory model than JavaScript. Understanding this model is key to writing efficient euv applications.
Linear Memory
WASM operates on a contiguous block of memory called "linear memory." This is a resizable ArrayBuffer that the WASM module can read and write. All data structures — strings, objects, arrays — are serialized into this linear memory as bytes.
┌──────────────────────────────────────────────┐
│ Linear Memory │
├──────────┬──────────┬──────────┬─────────────┤
│ Strings │ Objects │ Arrays │ Free Space │
└──────────┴──────────┴──────────┴─────────────┘
↑ Stack ↑ Heap
Rust's Ownership Model in WASM
Rust's ownership model works the same way in WASM as it does natively. When a Rust value goes out of scope, its memory is automatically freed. This is particularly powerful in the WASM context because:
- No garbage collector — Unlike JavaScript, there's no GC pause or overhead
- Deterministic deallocation — Memory is freed exactly when the value goes out of scope
- No memory leaks — The ownership system prevents leaks at compile time (in safe Rust)
fn app() -> VirtualNode {
// count is allocated when use_signal is called
let count: Signal<i32> = use_signal(|| 0);
let count_updater: Signal<i32> = count;
html! {
div {
// count is used here, so it stays alive
span { count }
button {
onclick: move |_event: Event| {
let current: i32 = count_updater.get();
count_updater.set(current + 1);
}
"Increment"
}
}
}
// When this function returns, local variables are dropped
// But count lives on because it's captured in the closure
}
Memory and the Virtual DOM
The virtual DOM in euv is a Rust data structure that lives in WASM's linear memory. When the renderer compares the old and new virtual DOM trees, it's doing this comparison entirely within WASM memory — no JavaScript round-trips needed.
// The VirtualNode is a Rust enum stored in WASM memory
enum VirtualNode<T> {
Element {
tag: Tag,
attributes: Vec<AttributeEntry>,
children: Vec<VirtualNode<T>>,
key: Option<String>,
props: Option<T>,
},
Text(TextNode),
Fragment(Vec<VirtualNode<T>>),
Dynamic(Rc<RefCell<DynamicNode<T>>>),
Empty,
}
The Dynamic variant is particularly interesting — it uses Rc<RefCell<>> to allow shared mutable access to dynamic nodes that need to re-render when their dependencies change.
Signal Memory Layout
Signals in euv are reference-counted reactive containers:
// Signal<T> internally holds:
// - The current value of type T
// - A set of subscriber callbacks (closures)
// - Reference counting for shared ownership
let count: Signal<i32> = use_signal(|| 0);
// count is a smart pointer (Rc<SignalInner<i32>>)
// SignalInner<i32> { value: 0, subscribers: Vec<...>, ... }
When you clone a signal (let count_updater: Signal<i32> = count;), you're incrementing the reference count, not copying the value. This makes signal cloning essentially free.
How euv Renders via WASM
The Rendering Process
When mount("#app", app) is called, the following happens:
- The renderer reads the CSS selector
#app(viaweb-sys) - It calls
app()to get the initialVirtualNodetree - It creates real DOM elements matching the virtual tree
- It inserts those elements into the
#appcontainer
use euv::*;
fn app() -> VirtualNode {
html! {
div {
h1 { "Hello, euv!" }
}
}
}
// mount internally:
// 1. Finds #app element via web-sys
// 2. Calls app() to get VirtualNode tree
// 3. Creates DOM elements
// 4. Inserts them into #app
mount("#app", app);
Incremental Rendering
When a signal changes, euv's renderer performs an incremental diff:
let count: Signal<i32> = use_signal(|| 0);
html! {
div {
// This becomes a DynamicNode that re-renders when count changes
span { count }
}
}
The renderer compares the old and new virtual DOM trees, computing the minimal set of changes needed:
- Same tag → Patch only changed attributes and children
- Different tag → Replace the entire DOM node
- Same text → Skip the update entirely
Keyed Diffing
For list rendering, euv supports keyed diffing to efficiently handle reordering:
struct Item {
id: String,
name: String,
}
html! {
ul {
for item in { items.get() } {
li {
key: item.id
item.name
}
}
}
}
When all children have a key attribute, the renderer uses a key-based diff algorithm that can reuse existing DOM nodes even when the list order changes. Without keys, it falls back to positional diffing (comparing by index).
Event Delegation
euv's renderer uses global event delegation for bubbling events. Instead of attaching individual event listeners to each element, it registers a single listener on window and dispatches events based on the element's ID:
html! {
button {
onclick: move |event: Event| {
// This handler is stored in a global registry
// and dispatched by the window-level listener
}
"Click me"
}
}
This dramatically reduces the number of DOM event listeners, improving both memory usage and performance.
Performance Characteristics
Why WASM is Fast
WebAssembly offers several performance advantages over JavaScript:
- Near-native speed — WASM instructions map closely to native CPU instructions
- No parsing overhead — WASM is a binary format that doesn't need parsing like JavaScript
- No GC pauses — Rust's ownership model means no garbage collection
- Predictable performance — No JIT warmup or deoptimization
Virtual DOM Diffing Performance
euv's virtual DOM diffing happens entirely in WASM memory, which offers several advantages:
// The diff computation is a pure Rust function
// No JavaScript round-trips during diffing
fn diff(old: &VirtualNode, new: &VirtualNode) -> PatchSet {
// ... comparison logic ...
}
The only JavaScript interaction happens when patches are applied to the real DOM (via web-sys).
Rendering Optimization
euv includes an important optimization: when a DynamicNode re-renders, the renderer compares the new virtual DOM output with the old one. If they're identical, the DOM patch is skipped entirely:
let count: Signal<i32> = use_signal(|| 0);
html! {
div {
// DynamicNode re-renders when count changes
span { { format!("Count: {}", count.get()) } }
// But if the formatted string hasn't changed,
// the DOM update is skipped
}
}
This is particularly effective for scenarios where signals update frequently but the rendered output doesn't change (e.g., a timer signal that updates every second but the display only shows minutes).
Batch Updates
The batch function groups multiple signal updates into a single DOM update:
use euv::*;
// Multiple set() calls within a batch are coalesced
batch(|| {
signal1.set(value1);
signal2.set(value2);
signal3.set(value3);
});
// Only one DOM update happens after the batch
The watch! macro also uses batching internally:
let celsius: Signal<f64> = use_signal(|| 0.0);
let fahrenheit: Signal<f64> = use_signal(|| 32.0);
watch!(celsius, |celsius_value: f64| {
fahrenheit.set(celsius_value * 9.0 / 5.0 + 32.0);
});
watch!(fahrenheit, |fahrenheit_value: f64| {
celsius.set((fahrenheit_value - 32.0) * 5.0 / 9.0);
});
When two signals watch each other, the batch mechanism ensures that cascading set calls within the same frame are merged into a single DOM update, preventing intermediate state flicker.
The Runtime Architecture
Hook System
euv's hook system manages the lifecycle of reactive primitives within dynamic nodes. Each DynamicNode has an associated HookContext that stores:
- Signal subscriptions
- Cleanup callbacks
- Interval handles
- Window event listeners
fn timer_tab() -> VirtualNode {
let elapsed: Signal<i32> = use_signal(|| 0);
let running: Signal<bool> = use_signal(|| false);
let handle: Signal<Option<IntervalHandle>> = use_signal(|| None);
// Register a cleanup callback
use_cleanup(move || {
if let Some(h) = handle.get() {
h.clear();
}
});
// Use watch! to react to signal changes
watch!(running, |is_running: bool| {
if is_running {
let elapsed_signal: Signal<i32> = elapsed;
let handle_signal: Signal<Option<IntervalHandle>> = handle;
let new_handle: IntervalHandle = use_interval(1000, move || {
let current: i32 = elapsed_signal.get();
elapsed_signal.set(current + 1);
});
handle_signal.set(Some(new_handle));
} else {
let handle_opt: Option<IntervalHandle> = handle.get();
if let Some(existing_handle) = handle_opt {
existing_handle.clear();
}
handle.set(None);
}
});
html! {
div {
span { { format_time(elapsed.get()) } }
if { !running.get() } {
button {
onclick: move |_event: Event| { running.set(true); }
"Start"
}
} else {
button {
onclick: move |_event: Event| { running.set(false); }
"Pause"
}
}
}
}
}
Component Lifecycle
Components in euv are functions that return VirtualNode. The #[component] attribute macro marks them for the html! macro to recognize:
use euv::*;
#[derive(Clone, Default)]
struct MyCardProps {
title: &'static str,
}
#[component]
pub fn my_card(node: VirtualNode<MyCardProps>) -> VirtualNode {
let MyCardProps { title, .. }: MyCardProps = node.try_get_props().unwrap_or_default();
let children: VirtualNode = node.try_get_child_node();
html! {
div {
class: c_card()
h3 {
class: c_card_title()
title
}
children
}
}
}
The component lifecycle is managed through the virtual DOM:
-
Creation — When a component tag is encountered in
html!, the component function is called - Update — When a parent re-renders, the component function is called again with new props
- Cleanup — When a component is removed from the DOM, its associated hooks are cleaned up
The Keep-Alive Pattern
In euv, using match or if/else to switch components destroys the old branch and creates a new one. The Keep-Alive pattern uses CSS display: none/block to preserve component state:
let tab: Signal<String> = use_signal(|| "counter".to_string());
html! {
div {
// Tab bar
div {
class: c_tab_bar()
div {
class: if { tab.get() == "counter" } { c_tab_item_active() } else { c_tab_item_inactive() }
onclick: move |_event: Event| { tab.set("counter".to_string()); }
"Counter"
}
div {
class: if { tab.get() == "form" } { c_tab_item_active() } else { c_tab_item_inactive() }
onclick: move |_event: Event| { tab.set("form".to_string()); }
"Form"
}
}
// Render all tabs simultaneously, control visibility with display
div {
style: { display: if { tab.get() == "counter" } { "block".to_string() } else { "none".to_string() }; }
{ counter_tab() }
}
div {
style: { display: if { tab.get() == "form" } { "block".to_string() } else { "none".to_string() }; }
{ form_tab() }
}
}
}
This keeps all DynamicNode instances and their HookContext alive — signals retain values, setInterval continues running, and form inputs preserve their state.
Working with JavaScript from WASM
The js-sys Bridge
When euv needs to interact with JavaScript (e.g., making HTTP requests), it goes through js-sys:
use euv::*;
// fetch is a JavaScript API accessed through js-sys/web-sys
let window: Window = window().expect("no global window exists");
let promise: Promise = window.fetch_with_str("https://httpbin.org/get");
let future: JsFuture = JsFuture::from(promise);
The web-sys Bridge
For browser APIs, euv uses web-sys:
use euv::*;
let win: Window = window().expect("no global window exists");
let location: Location = win.location();
let hash: String = location.hash().unwrap_or_default();
let _ = location.set_hash("#/about");
Type Conversion
Crossing the WASM boundary requires type conversion. euv handles this automatically through several traits:
// IntoNode trait - converts Rust types to VirtualNode
// String, &str, i32, bool, Signal<T>, etc. all implement IntoNode
let count: Signal<i32> = use_signal(|| 0);
html! {
div {
// count (Signal<i32>) is converted to VirtualNode via IntoNode
count
// String is converted via IntoNode
"Hello"
// i32 is converted via IntoNode
42
}
}
The IntoReactiveValue trait handles attribute value conversion:
// All of these can be used as attribute values:
html! {
div {
data_role: "container" // &str → AttributeValue::Text
data_id: "12345" // &str → AttributeValue::Text
aria_label: "Demo section" // &str → AttributeValue::Text
}
}
Conclusion
euv's use of WebAssembly is not just a compilation target — it's a fundamental design choice that shapes every aspect of the framework:
- Memory safety through Rust's ownership model, with no GC overhead
- Near-native performance for virtual DOM diffing and signal computations
- Efficient event handling through global delegation
- Deterministic resource management through the hook system and cleanup callbacks
The WASM compilation pipeline — from Rust source through wasm-bindgen to browser execution — is streamlined by tools like wasm-pack and the euv CLI. The result is a framework that brings the power of Rust's type system and performance to the browser, while maintaining seamless interoperability with the JavaScript ecosystem through js-sys and web-sys.
Understanding the WASM internals helps you write more efficient euv applications. Key takeaways:
- Signal cloning is cheap (reference counting, not deep copy)
- Virtual DOM diffing happens entirely in WASM memory
- Use
batchto group signal updates - Use the Keep-Alive pattern to preserve component state across tab switches
- All browser APIs are accessible through
use euv::*— no need for directjs-sysorweb-sysdependencies
Project Code:https://github.com/euv-dev/euv
Top comments (0)