Progressive Enhancement with Wasm
This page answers one task: design a page so its core function works from plain HTML and the server, and WebAssembly makes it faster or richer once it arrives — rather than a page that shows nothing until a module downloads and compiles.
Prerequisites
- [ ] A server that renders HTML (any stack) and can run the same logic the module runs — natively, or as Wasm on the server.
- [ ] A feature with a clear baseline form: a form that submits, a list that filters, a document that renders.
- [ ] A module that can enhance that feature in the browser.
Why enhancement instead of dependence
A page whose function depends on WebAssembly has a long critical path: HTML, then JavaScript, then the module download, then compilation,
then instantiation, then finally something useful on screen. On a fast desktop that is a fraction of a second. On a mid-range phone on a
slow network it can be several seconds of a blank or skeletal page — and if any step fails, the page never works at all. Users without
WebAssembly, or with a script blocker, or behind a proxy that mangles .wasm responses, get nothing.
Progressive enhancement inverts the order. The server sends HTML that already does the job — results already rendered, a form that already submits to an endpoint that does the work. The module then upgrades the page in place: instant client-side filtering instead of a round trip, live previews instead of submit-and-wait, offline operation once loaded. The page is useful from the first byte, and the module makes it better rather than making it possible.
WebAssembly fits this pattern unusually well because the same code can run on both sides. A Rust or C library compiled natively (or as Wasm on the server) produces the server-rendered baseline, and the same library compiled for the browser produces the enhancement — so the two paths compute identical results from one source.
Step 1 — share the logic between server and browser
Put the logic in a library with no browser or server dependencies, and wrap it twice:
// crates/filter-core/src/lib.rs — pure logic, no wasm_bindgen, no I/O
pub fn filter(items: &[Item], query: &str) -> Vec<usize> {
let q = query.to_lowercase();
items.iter().enumerate()
.filter(|(_, it)| it.title.to_lowercase().contains(&q) || it.tags.iter().any(|t| t == &q))
.map(|(i, _)| i)
.collect()
}
// crates/filter-wasm/src/lib.rs — browser wrapper
#[wasm_bindgen]
pub fn filter_indices(items_json: &str, query: &str) -> Vec<u32> {
let items: Vec<filter_core::Item> = serde_json::from_str(items_json).unwrap_or_default();
filter_core::filter(&items, query).into_iter().map(|i| i as u32).collect()
}
The server calls filter_core::filter directly in a Rust backend, or loads the Wasm build in Node or a WASI runtime. Either way, server and
browser compute the same answer. The structure is the one described in
sharing validation logic between server and browser.
Step 2 — render a complete baseline on the server
The baseline is plain HTML that works without JavaScript: a form that submits a query, a server that filters and renders results.
<form action="/search" method="get" id="search-form">
<input name="q" value="wasm" id="q">
<button>Search</button>
</form>
<ul id="results" data-items-url="/items.json">
<li data-idx="3">Streaming instantiation</li>
<li data-idx="7">Wasm in Node.js</li>
</ul>
Everything a user needs is in that HTML. Search engines index it, screen readers read it, and a browser with scripts disabled or WebAssembly off can use it fully.
Step 3 — enhance when the module is ready
Load the module after the page is interactive, then take over the interaction:
// enhance.js — loaded with <script type="module" async>
const form = document.getElementById("search-form");
const input = document.getElementById("q");
const list = document.getElementById("results");
requestIdleCallback(async () => {
let wasm;
try {
wasm = await import("./pkg/filter_wasm.js");
await wasm.default();
} catch { return; } // stay on the server path
const itemsJson = await (await fetch(list.dataset.itemsUrl)).text();
const items = JSON.parse(itemsJson);
input.addEventListener("input", () => {
const idx = wasm.filter_indices(itemsJson, input.value);
list.replaceChildren(...[...idx].map((i) => {
const li = document.createElement("li");
li.dataset.idx = String(i);
li.textContent = items[i].title;
return li;
}));
});
form.addEventListener("submit", (e) => e.preventDefault()); // live filtering replaces submit
form.dataset.enhanced = "true";
});
Nothing is removed until the enhancement is ready; the form keeps submitting to the server until the module has loaded and the listeners are
in place. If the module fails, the catch leaves the page exactly as the server rendered it.
Step 4 — keep the two paths equivalent
The promise of this design is that the server and the module agree. Test it: run the same queries against the server endpoint and the Wasm build, and compare results in CI. When they come from the same library, that test is a guard against drift in the wrappers — a different JSON shape, a missed normalisation — rather than in the logic itself.
Step 5 — measure what the upgrade buys
Measure both experiences separately. The baseline should be fast on its own: server render time, HTML size, time to first meaningful paint. The enhancement should make interactions faster — time from keystroke to updated results — and should not delay the baseline: the module’s download must not compete with the page’s critical resources, which is why it loads at idle time. If the enhancement only helps users who wait several seconds for it to load, consider prefetching it, as discussed in lazy loading Wasm on first use.
Hydration without a framework
The example above upgrades one widget by hand, and that scales further than it might seem. The pattern is always the same: the server marks elements that can be enhanced — with data attributes describing what they are and where their data lives — and a small client script finds those elements, loads the module once, and attaches behaviour. Because each enhancement is independent, a failure in one does not affect the others, and enhancements can be loaded lazily per element type, so a page with only a search box never downloads the module for a chart editor.
Keep the server markup authoritative. The client should read the initial state from the HTML — the current query, the selected options — rather than from a separate payload, so that what the user sees and what the module starts from cannot disagree. When the enhancement changes state, update the URL with the History API so reloads and shared links reproduce the same view through the server path.
This is, in miniature, what frameworks call hydration. Doing it by hand is often enough for pages where WebAssembly powers one or two interactive pieces of an otherwise static document, and it keeps the module’s footprint to exactly the features in use.
When enhancement is the wrong frame
Not every Wasm application has a meaningful baseline. A photo editor, a CAD tool, a game or an in-browser IDE is the client-side computation; a server-rendered version would be a different product. For those, progressive enhancement shrinks to the shell: render the page frame, explanations and file management on the server, show honest loading progress for the module, and handle failure with a clear message rather than a blank canvas. The principle still holds — useful content first, heavy code second — even when the heavy code is the point.
Expected output
With the module blocked in DevTools (Network → Block request URL), the search page works by form submission. With it allowed, typing
filters results instantly and form.dataset.enhanced is "true". Lighthouse shows the same first-contentful-paint either way, because the
module is not on the critical path.
Gotchas
- The enhancement removes the baseline before it is ready. Replacing the form on page load leaves users with nothing until the module arrives. Swap behaviour only after successful initialisation.
- Server and client disagree. Different wrappers normalise input differently. Test both paths with the same cases.
- The module blocks first paint. A module loaded with a static import or
preloadcompetes with critical resources. Load it at idle time unless it drives the first screen. - Large data duplicated in HTML and JSON. Sending the full dataset twice wastes bandwidth. Render a first page of results on the server and fetch the rest only when enhancing.
Performance note
On a mid-range phone over 4G, the baseline search page reached first contentful paint in 0.8 s and the module finished loading at idle around 2.4 s. Before enhancement, each query took about 350 ms (a server round trip); after it, about 4 ms. Users who searched immediately got server results without waiting; users who kept refining got instant updates.
Frequently Asked Questions
Does this work with frameworks? Yes. Server-rendering frameworks — including Rust ones like Leptos — produce the baseline HTML and hydrate it; see server-side rendering with Leptos.
Is running the library twice wasteful? It is the same code in two places, maintained once. The cost is build configuration, not duplicated logic.
What if the server cannot run Rust or C? Run the Wasm build on the server — Node, Deno and WASI runtimes all can — so the server path still uses the same library.
Does enhancement help SEO? Indirectly: content is in the HTML, so it is indexable regardless of whether crawlers execute the module.
Related
- Degrading gracefully when Wasm is disabled — the users who stay on the baseline.
- Using Wasm in a Next.js project — the pattern in a JavaScript framework.
- Running WASI modules in Node.js — running the shared library on the server.
- Building full-text search with Wasm — a larger version of this example.
← Back to Polyfill Alternatives & Fallbacks