Full-Stack Frameworks with Wasm

There are two quite different ambitions in this area, and conflating them produces bad decisions. One is to write the whole interface in a compiled language, replacing the JavaScript framework entirely. The other is to keep the interface in JavaScript and move specific logic — validation, calculation, parsing — into a module shared with the server. The first is a framework choice with a large payload and a real productivity story; the second is an integration pattern with a small payload and a correctness story.

Prerequisites

  • [ ] Rust with wasm-pack, or a .NET or Go toolchain if you are evaluating those.
  • [ ] A bundler that handles .wasm imports — Vite, Webpack 5, Rspack or Next.js.
  • [ ] A realistic view of payload budgets for your users.
  • [ ] An existing application, if the question is integration rather than greenfield.

Where the DOM work actually happens

Nothing in a browser lets a WebAssembly module touch the DOM directly. Every framework in this space therefore calls into JavaScript to do it, and the differences between them are in how much crossing that requires and how it is generated.

Rust frameworks such as Leptos, Yew and Dioxus maintain their own component model in compiled code and apply changes to the DOM through generated bindings. Fine-grained reactive frameworks update only the nodes whose values changed, which means very few crossings per update. Virtual-DOM frameworks diff a tree in compiled code and then apply a patch list, which is more crossings but a familiar model.

Blazor WebAssembly runs a .NET runtime compiled to WebAssembly, with your code as intermediate language on top — a larger payload and a completely familiar environment for a .NET team.

Three ways to reach the DOM from compiled code A fine-grained reactive framework updates individual nodes with few crossings. A virtual-DOM framework diffs in compiled code and applies a patch list. A hosted runtime adds an interpreter layer between your code and the bindings. fine-grained reactive signals track dependencies updates one text node very few crossings Leptos, Dioxus signals virtual DOM diff in compiled code apply a patch list familiar mental model Yew hosted runtime a whole VM in the module your code on top of it largest payload Blazor WebAssembly All three reach the DOM through JavaScript bindings; what differs is how many crossings an update costs and how much runtime ships to make it possible.

The payload question, answered honestly

For a framework that replaces JavaScript entirely, the module contains your application and its runtime. That is a different shape of cost from a JavaScript bundle, and it is not always worse — but it has a floor that a JavaScript framework does not.

Approach Typical first load (compressed)
A small React application 45–120 kB
Leptos or Dioxus application 180–400 kB
Yew application 250–500 kB
Blazor WebAssembly 1.2–2.5 MB
A module integrated into a JS app 20–150 kB on top of the JS

The floor matters most for a public page where first load is the metric. It matters much less for an application behind a login, used daily, where the module is cached after the first visit — which is exactly the situation where these frameworks are most often a good fit.

The strongest case: shared logic, not shared rendering

The integration pattern has a clearer argument than the framework one. A validation rule, a pricing calculation or a parser that exists once and runs identically on the server and in the browser removes a whole category of bug — the two implementations that drift apart.

// compiled once, used in both places
pub fn validate_order(order: &Order, rules: &Rules) -> Result<Validated, Vec<Violation>> { … }

The server enforces it because it must; the browser runs it for immediate feedback. Neither can disagree with the other, because they are the same instructions. This is covered in detail in sharing validation logic between server and browser, and it is the pattern most teams should reach for first.

The integration pattern in practice

Most teams arrive here with an existing JavaScript application and a specific piece of logic that would be better compiled. The shape that works is deliberately small: one module, one clear boundary, loaded when it is needed.

Keep the module’s interface coarse. A single evaluate(inputBytes) -> outputBytes crossing per user action costs nothing; a call per field, per row or per keystroke reintroduces the overhead the module was meant to remove. Where the interface must be finer, batch on the JavaScript side and cross once.

Load it lazily and behind a capability check. The application should function without the module — with server-side validation, a slower JavaScript path, or a disabled feature — and improve when it arrives. That keeps a failed fetch from becoming an unusable page, and it keeps the module off the critical path for users who never reach the feature.

let enginePromise = null;
export function engine() {
  enginePromise ??= import('./engine/engine.js').then(async (m) => { await m.default(); return m; });
  return enginePromise;
}

// at the call site
const { evaluate } = await engine();
const result = evaluate(encode(form));

Keep the encoding boring. JSON over the boundary is fine for anything under a few hundred kilobytes, and the parse cost is dwarfed by everything else in a user interaction. Optimise it only when a measurement says so, and version it when you do.

Server-side rendering and hydration

Both ambitions eventually meet server-side rendering, and the story differs.

Rust frameworks increasingly render on the server — the same components, compiled natively, producing HTML — and then hydrate in the browser once the module loads. That gives fast first paint with the interactive layer arriving later, exactly as a JavaScript framework does, with the same caveat: the page is visible before it is interactive, and the gap is longer because the module is larger.

For the integration pattern there is no hydration question, because the interface is JavaScript and only the logic is compiled. The module can be loaded lazily, after first paint, and the page works without it until it arrives.

Visible early, interactive later Server-rendered HTML paints quickly while the module downloads and compiles. Interactivity begins at hydration, and the gap between the two is longer for a larger module. HTML — visible module downloads and compiles — page looks ready, is not hydrated — interactive The middle band is where users click things that do nothing. Its length is proportional to module size, which is the practical reason payload matters. Disable or visibly mark interactive controls until hydration completes rather than letting them silently swallow input. For the integration pattern there is no band at all: the interface is already interactive and the module adds capability when it arrives.

State, forms and the parts frameworks make you rebuild

An interface is mostly not rendering. It is forms, validation messages, focus management, routing, accessibility semantics and the dozens of small behaviours users expect, and a compiled framework has to provide all of them or you write them.

Forms are the clearest example. A JavaScript ecosystem has mature libraries for field state, validation display, dirty tracking and submission handling; the equivalent in a compiled framework is younger and thinner. Writing that layer once is a week; writing it repeatedly across projects is a reason to think carefully.

Routing and code splitting are similar. Splitting a JavaScript bundle by route is routine; splitting a compiled module is not, because the module is one artifact. Some frameworks address this with lazily loaded secondary modules, but the granularity is coarser and the tooling less automatic, which means a large application ships more of itself up front than its JavaScript equivalent would.

Accessibility deserves a specific mention. Generated DOM is still DOM, and the same rules apply: roles, labels, focus order and keyboard behaviour. The frameworks do not get this wrong on your behalf, but they also do not have the ecosystem of audited component libraries that a JavaScript project can pull from, so more of it is yours to get right.

None of this argues against a compiled interface. It argues for counting the whole cost — rendering is the part these frameworks solve, and it is not the part that takes the time.

Where compiled interfaces genuinely win

Three situations make a compiled interface framework the right answer rather than an interesting one.

An application whose logic is already in Rust — a simulation, an editor, a design tool, a financial model — gains enormously from writing the interface in the same language, with the same types, and no marshalling between the two. The alternative is maintaining a boundary that produces bugs on both sides.

A team whose expertise is in the compiled language and not in JavaScript will be more productive in the environment they know, and the strong type system across the entire application removes a class of error that a JavaScript front end handles with runtime validation.

And an interface doing heavy computation as part of rendering — a spreadsheet recalculating, a diagram laying out, a large table sorting and filtering — benefits from the computation being adjacent to the rendering rather than across a boundary.

Outside those, a JavaScript framework with a compiled module for the heavy parts is usually the better engineering trade: smaller, more familiar, easier to hire for, and with an ecosystem that answers most questions before you ask them.

Tooling and the developer experience

Whichever direction you take, the day-to-day experience is decided by how well the build integrates, and this is where compiled front ends still cost more than JavaScript ones.

Incremental rebuild time is the big one. A Rust interface framework rebuilds in seconds to tens of seconds depending on crate size, against tens of milliseconds for a JavaScript hot reload. Frameworks in this space have improved considerably — watch modes, incremental compilation, hot-reloading of view macros — but the gap is real and it is felt most during interface work, which is exactly the work these frameworks are for.

Debugging is the second. With DWARF information and the browser’s extension, you can step through Rust in the devtools, which works well enough to be genuinely useful. Without it you get addresses. Keeping debug information in development builds and stripping it for release is the obvious answer, and the DWARF debugging guide covers the setup.

Testing splits cleanly. Logic tests run natively, fast, with the whole ordinary toolchain. Interface tests need a browser, which means wasm-bindgen-test in a headless browser and a slower loop. Structure the application so the majority of behaviour is testable natively, and the browser tests cover wiring rather than logic.

Finally, error reporting. A panic in release mode without the panic hook is RuntimeError: unreachable executed, which tells nobody anything. Install a hook that logs the panic message and, in production, reports it with enough context to identify the component — the cost is a few kilobytes and it converts an unusable error into an actionable one.

How much of the page the module owns The approaches differ in one thing: whether the module renders the interface, renders part of it, or renders none of it and only does the work. whole-app rendering the module owns the DOM; the payload is large islands a few components in Wasm inside a JavaScript page logic only no rendering at all; the module computes and returns shared validation the same rules run on the server and in the page The payload grows with how much the module owns, and so does the time before anything is interactive. Logic-only is the safest default: it adds capability without changing how the page is built.

Gotchas and failure modes

  • Bundler moves the glue but not the .wasm. The single most common integration failure; the module 404s in production and works locally.
  • Server-side rendering that imports the module. In Next.js and similar, a module imported at the top level runs during the server build and fails. Load it in a client component or dynamically.
  • Payload measured uncompressed. Quote the compressed number; the difference is a factor of three.
  • Hydration mismatch. Server-rendered HTML that disagrees with what the module produces causes a visible flash and a console warning.
  • A module per component. Each instance holds its own memory; share one instance across the application.
  • Assuming the module is available synchronously. Instantiation is asynchronous; guard every call site or await once at startup and render nothing until it resolves.

Verifying an integration

The checks worth automating are about delivery rather than logic, because logic has ordinary tests:

// the module is reachable at the URL the built glue expects
const res = await fetch(wasmUrl, { method: 'HEAD' });
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'application/wasm');

// and the page works before it loads
await page.route('**/*.wasm', (r) => r.abort());
await page.goto('/orders/new');
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();   // server still validates

The second test is the valuable one. An interface that becomes unusable when the module fails to load is a fragile design; one that degrades to server-side validation keeps working, which is the same progressive-enhancement argument that applies to any optional asset.

Making the decision

The question that settles this is not “which is faster” but “what is the dominant cost of this application”. Three answers point three different ways.

If the dominant cost is first load on a public page seen once by each visitor, the compiled framework’s payload floor is disqualifying and the integration pattern — or no module at all — is the answer. A marketing site does not need a compiled interface, and shipping one is a choice users pay for.

If the dominant cost is the correctness of logic that exists on both sides of the network, the shared module is the answer regardless of what renders the page. This is the case most teams have and the one with the clearest return, because it removes an entire class of bug rather than shifting milliseconds.

If the dominant cost is computation inside the interface itself — a model recalculating as the user drags a slider, a large document reflowing, a simulation running while the user edits it — then having the computation and the rendering in the same compiled world is worth the payload, and a compiled framework earns its place.

Write down which of the three you have before evaluating frameworks. The evaluation is much shorter afterwards, and it stops the conversation becoming a preference argument between people who have each answered a different question.

Guides in this topic

Frequently Asked Questions

Is a Rust interface framework faster than React? For computation during rendering, yes, sometimes dramatically. For ordinary interface work the bottleneck is the DOM itself, which both must go through, so the difference is small and can favour either depending on how many crossings each update costs.

Can I use these frameworks incrementally? The integration pattern is inherently incremental — one module, one feature. A full framework is not: it owns the page, so adopting it means rewriting an application rather than extending one.

What about hiring and maintenance? A real consideration. A JavaScript front end has an enormous talent pool and a mature ecosystem; a Rust front end has a much smaller one. For an internal tool with a Rust team that is fine, and for a long-lived product with turnover it is a risk worth naming explicitly.

How does this interact with a design system? Awkwardly, if the design system is a JavaScript component library. A compiled framework cannot consume React components, so you either reimplement the design system’s components in the compiled framework, wrap them through custom elements, or accept two implementations. Web components are the most workable bridge and are worth considering when a shared design system is a requirement.

Can I render a compiled component inside a JavaScript application? Through a custom element, yes: the module renders into a shadow root and the surrounding application treats it as an opaque element. That is a reasonable way to embed a genuinely heavy component — an editor, a chart, a simulation — without adopting a framework for the whole page.

What happens to all this when the component model lands properly? The marshalling between host and module becomes generated from a typed interface rather than hand-written, which removes a category of integration bug and makes sharing a module across languages far easier. It does not change the payload arithmetic or the DOM access story, so the decision framework above survives it intact.

← Back to Production Wasm: Workloads & Deployment