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
.wasmimports — 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.
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.
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.
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
- Building a UI with Leptos — a fine-grained reactive framework end to end.
- Comparing Yew, Leptos and Dioxus — models, payloads and where each fits.
- Integrating Wasm into a React app — the pattern most teams actually need.
- Using Wasm in a Next.js project — server components, bundling and the traps.
- Sharing validation logic between server and browser — one implementation, two runtimes.
- Blazor WebAssembly for JavaScript developers — what the .NET approach looks like from outside.
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.
Related
- ESM Bindings & Module Generation — how the glue and the module reach the browser.
- wasm-bindgen Deep Dive — the binding layer these frameworks build on.
- Serverless & Edge Deployment — running the shared logic on the other side.
← Back to Production Wasm: Workloads & Deployment