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
webtarget output insrc/lib/wasm/or an npm package. - [ ] Familiarity with SvelteKit’s
+page.svelte,+page.tsand+page.server.tsfiles.
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.
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.
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
onMountor guard withbrowser. - Global
ssr = false. It disables server rendering everywhere. Turn it off per route only where needed. .wasmmissing 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
.wasmfrom 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.
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.
Related
- Integrating Wasm into a React app — the same problems in React.
- Using Wasm in a Vue app — a composable approach.
- Using Wasm in a Next.js project — another SSR framework.
- Bundling Wasm ESM with Vite — the bundler underneath.
← Back to Full-Stack Frameworks with Wasm