Using Wasm in a SvelteKit App

This page answers one task: a SvelteKit application needs a WebAssembly module — an image processor, a parser, a markdown renderer — and it must work with server-side rendering, prerendering and client navigation, without crashing the server build or bloating every page.

Prerequisites

  • [ ] A SvelteKit project (Vite-based, Svelte 5).
  • [ ] A Wasm package with ES-module glue, for example wasm-bindgen’s web target output in src/lib/wasm/ or an npm package.
  • [ ] Familiarity with SvelteKit’s +page.svelte, +page.ts and +page.server.ts files.

Where code runs in SvelteKit

A SvelteKit page can run in three places. During server-side rendering (SSR) and prerendering, components and universal load functions run in Node (or the adapter’s server runtime). After hydration, the same components run in the browser, and client-side navigation runs universal load functions in the browser too. Server-only files (+page.server.ts, +server.ts, hooks.server.ts) run only on the server.

Wasm glue written for browsers often assumes fetch of a relative URL, window or document, and fails during SSR — the classic symptom is a build or render error like “fetch failed” or “window is not defined”. The fix is to decide, for each use, where the module should run: browser only (most UI features), server only (heavy work behind an endpoint), or both (shared logic such as validation), and load it accordingly.

Where to load a Wasm module in SvelteKit A browser-only feature initialises the module in onMount with a dynamic import. Server-only work loads it in +page.server.ts or an endpoint using a Node loader. Shared logic used during SSR and in the browser needs a loader that works in both environments. Where does the Wasm work need to run? browser only dynamic import inside onMount server only +page.server.ts / +server.ts, fs loader both (SSR + browser) environment-aware loader, browser flag

Step 1 — initialise browser-only modules in onMount

onMount runs only in the browser, after the component has hydrated. A dynamic import() inside it keeps the glue and the .wasm out of the SSR bundle and out of the initial client bundle:

<!-- src/routes/editor/+page.svelte -->
<script lang="ts">
  import { onMount } from "svelte";

  let engine: typeof import("$lib/wasm/imagekit.js") | null = $state(null);
  let status = $state("loading…");

  onMount(async () => {
    const mod = await import("$lib/wasm/imagekit.js");
    await mod.default();                      // wasm-bindgen init: fetches the .wasm via import.meta.url
    engine = mod;
    status = "ready";
  });

  async function onFile(e: Event) {
    const file = (e.target as HTMLInputElement).files?.[0];
    if (!file || !engine) return;
    const out = engine.resize(new Uint8Array(await file.arrayBuffer()), 800);
    // … show result
  }
</script>

<p>{status}</p>
<input type="file" accept="image/*" onchange={onFile} disabled={!engine} />

The server renders the page with the input disabled and “loading…”, which is honest: the feature is not usable until the module arrives. For heavy work, move the module into a worker, as in keeping the UI responsive during long Wasm tasks.

Step 2 — let Vite handle the .wasm asset

wasm-bindgen’s web target locates its binary with new URL("…_bg.wasm", import.meta.url), which Vite recognises: in production builds it copies the file to _app/immutable/assets/ with a content hash and rewrites the URL. No plugin is needed. For packages that import a .wasm file directly (the bundler target), add vite-plugin-wasm and vite-plugin-top-level-await, or switch the package to the web target. Exclude Wasm packages from Vite’s dependency pre-bundling if the dev server serves the binary with the wrong path:

// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [sveltekit()],
  optimizeDeps: { exclude: ["@my-org/imagekit"] },
});

Development-server specifics are in configuring the Vite dev server for Wasm.

Step 3 — use the module on the server

For work that should happen on the server — rendering markdown at request time, generating images for social cards — load the module in server-only code with a Node loader, and cache the instance at module scope so it is reused across requests:

// src/lib/server/markdown.ts
import { readFile } from "node:fs/promises";
import { initSync, render } from "$lib/wasm/markdown.js";

let ready: Promise<void> | undefined;
export function ensureMarkdown() {
  ready ??= readFile(new URL("../wasm/markdown_bg.wasm", import.meta.url)).then((bytes) => { initSync({ module: bytes }); });
  return ready;
}
export { render };
// src/routes/blog/[slug]/+page.server.ts
import { ensureMarkdown, render } from "$lib/server/markdown";
export async function load({ params }) {
  await ensureMarkdown();
  return { html: render(await getPostSource(params.slug)) };
}

Check where the .wasm file ends up after vite build for your adapter; with adapter-node it is copied alongside the server chunks, but some adapters need the file listed explicitly or imported as an asset. Edge adapters (Cloudflare, Vercel Edge) usually require importing the .wasm as a module instead of reading it from disk.

A Wasm-backed page across server render and hydration The server load function initialises the module once per process and renders HTML with it. The page is sent with that HTML. In the browser, hydration runs, and onMount dynamically imports the browser build of the module for interactive features. request /blog/wasm-memory server load Wasm render (cached instance) HTML response content already rendered hydration no Wasm needed to show onMount lazy-load Wasm for interactivity

Step 4 — share logic between server and browser

When the same module must run during SSR and in the browser — validation, formatting — guard environment-specific loading with SvelteKit’s browser flag from $app/environment:

import { browser } from "$app/environment";

export async function getValidator() {
  if (browser) {
    const m = await import("$lib/wasm/validate.js");
    await m.default();
    return m;
  }
  const { loadValidatorNode } = await import("$lib/server/validate-node");
  return loadValidatorNode();
}

Keep the public API identical in both branches, so callers do not care where they run. The broader pattern is in sharing validation logic between server and browser.

Step 5 — prerendering and adapters

Pages marked export const prerender = true run their load functions at build time in Node; Wasm used there follows the server path from step 3 and its output is baked into static HTML. Pages with ssr = false render only in the browser, which sidesteps SSR issues but gives up server rendering for that page — acceptable for app-like tools, a loss for content pages. Choose per route rather than disabling SSR globally.

Wrapping the module in a store

When several components on a page use the same module, load it once and share it through a small module-level store rather than letting each component import and initialise it. A function that returns a cached Promise — the single-flight pattern from designing a promise-based API around a Wasm module — works well with Svelte 5’s runes: a $state variable holds the ready module, set when the Promise resolves, and components derive their enabled state from it. Keep the store free of side effects at import time, so importing it during SSR does nothing; trigger loading from onMount in the first component that needs it.

Keeping Wasm off pages that do not need it

SvelteKit splits code per route, so a module imported dynamically on one page does not affect others — as long as nothing imports it statically from a shared place. Watch three common leaks. A shared $lib/index.ts that re-exports the Wasm glue pulls it into every page that imports anything from $lib. A layout component that imports the module puts it on every child route. And a store created at module scope that initialises the module runs on every page that touches the store. Check the build output’s route manifest or use vite-bundle-visualizer to confirm the .wasm asset and its glue appear only in the routes that need them. Preload the module on hover or when the user navigates toward the feature — import() it in a mouseenter handler on the link — so that by the time the page opens, the binary is already in the HTTP cache. The general technique is covered in lazy loading Wasm on first use.

Expected output

npm run build succeeds with no SSR errors; the editor page server-renders with the input disabled, enables it within a few hundred milliseconds after hydration, and processes images in the browser; blog pages render markdown on the server via Wasm; and the .wasm file appears in the build output only for routes that use it.

Gotchas

  • Top-level Wasm imports in components. They run during SSR and fail. Import inside onMount or guard with browser.
  • Global ssr = false. It disables server rendering everywhere. Turn it off per route only where needed.
  • .wasm missing in production server builds. Some adapters do not copy runtime-read files. Verify the build output.
  • Edge adapters reading files. Edge runtimes cannot read the .wasm from disk. Import it as a module there.
  • Re-initialising per request. Cache the instance at module scope on the server.
  • Shared barrels re-exporting glue. They put Wasm on every page. Keep Wasm imports local to the routes that use them.

Performance note

On the editor route, lazy-loading the 610 KB (190 KB compressed) module after hydration kept the page’s Largest Contentful Paint at 1.1 s on a throttled connection, versus 1.9 s when the module was imported statically. Server-side markdown rendering with a cached instance took about 0.8 ms per post.

Largest Contentful Paint on the editor route Seconds to Largest Contentful Paint on a throttled mobile connection with the Wasm module imported statically into the page bundle and loaded dynamically after hydration. seconds to LCP static import 1.9 s dynamic import in onMount 1.1 s

Frequently Asked Questions

Can I use $state objects that hold Wasm handles? Yes, but remember to free exported Rust objects when components are destroyed, in the onMount cleanup function.

Does SvelteKit support Wasm in service workers? Yes — src/service-worker.ts can import and use Wasm modules, built by Vite like other code.

What about form actions? They run on the server, so use the server-side loader from step 3.

How do I test components that use Wasm? With Vitest and jsdom or happy-dom, load the module through the Node path, or mock the wrapper’s API.

Is vite-plugin-wasm needed? Only for packages that use ES-module imports of .wasm files. Glue using new URL(…, import.meta.url) works without it.

← Back to Full-Stack Frameworks with Wasm