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.

A page that works first and upgrades second The server renders HTML using the shared library, so results are visible immediately. The browser then loads the Wasm build of the same library in the background and, once ready, takes over interactions client-side. If the module never loads, the server path keeps working. request GET /search?q=wasm server renders shared lib, native or Wasm usable HTML results visible, form works module loads background, after first paint enhanced page instant client-side updates Every step after "usable HTML" is optional — a failure leaves the page working, just slower to respond.

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.

The same search with and without the enhancement Before the module loads, typing and pressing Enter submits the form and the server responds with new HTML. After the module loads, each keystroke is filtered locally by the Wasm build of the same library and the list updates without a request. user page Wasm module server types query, presses Enter (before module) GET /search?q=… → filter in shared lib new HTML with results types (after module loaded) filter_indices(items, q) — no request indices; list updates instantly

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 preload competes 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.

Search latency before and after enhancement Time from submitting or typing a query to updated results on a mid-range phone over 4G, using the server path before the module loads and the Wasm path after. ms per query server path (form submit) 350 ms Wasm path (client filter) 4 ms

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.

← Back to Polyfill Alternatives & Fallbacks