Resolving Wasm URLs with import.meta.url
This page answers one task: make the code that loads a WebAssembly module find the .wasm file reliably, no
matter what page path it runs on, whether it is served from a CDN, loaded in a worker, or processed by a bundler.
Prerequisites
- [ ] JavaScript that loads a module with
fetchorinstantiateStreaming. - [ ] Module scripts —
<script type="module">, a module worker, or a bundler that outputs ES modules.import.metadoes not exist in classic scripts. - [ ] A way to serve the app from a sub-path, for testing:
/app/instead of/.
Why relative paths break
fetch("module.wasm") resolves the path against the page’s URL, not against the script that contains the
call. That is fine while the page and the script live in the same directory. It breaks the moment they do not:
a page at /docs/guide/ loading a script from /static/js/ makes the fetch look for /docs/guide/module.wasm.
It breaks again in a worker, where relative URLs resolve against the worker script, and again when a library
installed from npm is bundled into an application whose page has nothing to do with the library’s layout.
import.meta.url is the absolute URL of the module currently executing. Resolving the .wasm path against it
anchors the file to its loader, wherever both end up.
Step 1 — use the pattern
// static/js/app.js
const wasmUrl = new URL("./module.wasm", import.meta.url);
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports);
Two details make this robust. The relative reference starts with ./, which is unambiguous to both browsers and
bundlers. And the new URL(...) expression is written literally at the call site, with a string literal as the
first argument — bundlers detect exactly that shape, and a computed string or a helper function hides it from
them.
Step 2 — confirm your toolchain already does it
Most generators emit this pattern for you, which is why it is worth recognising:
// wasm-pack --target web: pkg/app.js
if (typeof module_or_path === 'undefined') {
module_or_path = new URL('app_bg.wasm', import.meta.url);
}
// Emscripten -sEXPORT_ES6: dist/codec.mjs
var _scriptName = import.meta.url;
// ... later, locateFile() resolves 'codec.wasm' against _scriptName
If the generated loader already resolves against import.meta.url, the file only has to sit next to the
JavaScript in the deployed output. Problems almost always come from a build step that moves one and not the other.
Step 3 — let the bundler rewrite it
Vite, webpack 5 and Rollup (with an assets plugin) recognise new URL("./x.wasm", import.meta.url), emit the file
with a content hash, and rewrite the expression to the hashed URL. You get cache-busting for free.
// what you write
const url = new URL("./module.wasm", import.meta.url);
// what the bundle contains after vite build
const url = new URL("/assets/module-5d41a2c8.wasm", import.meta.url);
esbuild is the notable exception: it leaves the expression unchanged and does not copy the file, so the
reference only works if the .wasm is copied next to the bundle output — covered in
loading Wasm in Rollup and esbuild.
Why the pattern matters more for libraries than for apps
In an application you control the whole deployment, and a hard-coded path is merely fragile. In a library it is a
bug waiting for its first user. A package author cannot know whether the consumer will serve the app from the
domain root or a sub-path, whether a bundler will move the JavaScript into an assets/ directory with a hashed
name, whether the code will run in the main thread, a worker, a service worker or Node, or whether the whole thing
will be served from a CDN on a different origin. Every one of those changes what a page-relative or root-relative
path points at.
import.meta.url sidesteps all of those questions because it answers a different one: not “where is the page?”
but “where am I?”. The library’s loader and its binary are shipped together in the package, so wherever the loader
ends up, the binary is next to it — or, after a bundler has rewritten the reference, wherever the bundler put it.
The library does not need to know.
That is also why it is worth resisting the temptation to “fix” a loading problem in a consuming application by patching the library’s path. The fix usually belongs in the build configuration — copying the asset, enabling the bundler’s URL handling — and a patched path will break again on the next deployment change.
Step 4 — handle CDNs and cross-origin scripts
When the JavaScript is served from a CDN, import.meta.url is the CDN URL, and the .wasm will be fetched from
the CDN too. That is usually what you want, but it makes the fetch cross-origin, which has two consequences.
The CDN must send Access-Control-Allow-Origin on the .wasm response, because fetch is subject to CORS even
though a <script> tag is not. And the CDN must send Content-Type: application/wasm, or
instantiateStreaming rejects the response.
HTTP/2 200
content-type: application/wasm
access-control-allow-origin: *
cache-control: public, max-age=31536000, immutable
On a cross-origin-isolated page, the response also needs Cross-Origin-Resource-Policy: cross-origin, or the
embedder policy blocks it. The headers are covered in
serving Wasm files with the right headers.
Step 5 — let callers override the location
A library cannot know every deployment, so expose an escape hatch. The common convention is an optional argument
to the initialiser that accepts a URL, a Response, or the bytes themselves:
export async function init(source) {
source ??= new URL("./module.wasm", import.meta.url);
const response = source instanceof Response ? source
: source instanceof URL || typeof source === "string" ? fetch(source)
: null;
const { instance } = response
? await WebAssembly.instantiateStreaming(response, imports)
: await WebAssembly.instantiate(source, imports); // bytes or a Module
return instance.exports;
}
An application that serves its Wasm from a dedicated path, or has already fetched the bytes for a progress bar, can then pass them in rather than fighting the default.
Document the option in the library’s README with one example for each common case — a custom asset path, bytes
already in memory, and a precompiled WebAssembly.Module shared between workers. Users who hit a loading problem
look there first, and an explicit override beats them patching your package in node_modules.
Expected output
On a page served from a sub-path with the script on a different path, the Network panel shows a single request for the module at the script’s location:
GET /static/js/module.wasm 200 application/wasm 38.2 kB
and no failed request for /docs/guide/module.wasm. Logging the resolved URL during development makes the
behaviour obvious:
console.debug("wasm url:", new URL("./module.wasm", import.meta.url).href);
Gotchas
import.meta may only appear in a module. The script is loaded as a classic script. Addtype="module"to the tag, or{ type: "module" }to theWorkerconstructor.- The bundler did not rewrite the URL. The expression was not a literal — for example
new URL(name, import.meta.url)with a variable. Write the file name literally. - Works in dev, 404 in production. The dev server serves source files directly; the production build moved the
JavaScript and did not copy the
.wasm. Check that the build output contains the binary. file://URLs in Node.import.meta.urlis afile:URL in Node, andfetchin older Node releases does not supportfile:URLs. Read the file withfs.readFile(new URL(...))instead, as in loading Wasm in Node.js with ES modules.
Performance note
Resolving the URL costs nothing measurable. The performance consequence is indirect but real: the pattern lets
bundlers give the file a content-hashed name, and a hashed name can be cached as immutable. On a returning visit,
an immutable .wasm was served from the HTTP cache with no revalidation request, and Chrome’s compiled-code cache
skipped compilation entirely — the module was ready 290 ms sooner than with an unhashed name that had to be
revalidated.
Frequently Asked Questions
Does import.meta.url work in Web Workers?
In module workers, yes. In classic workers, use self.location as the base instead, or switch to a module worker.
Can I use import.meta.resolve instead?
import.meta.resolve("./module.wasm") returns the same absolute URL and also applies import maps. Bundler support
for detecting it is newer than for the new URL form, so the latter remains the safer default.
What about server-side rendering frameworks?
During server rendering the module code runs in Node, where import.meta.url is a file: URL inside the build
output. Load the module only in client code — inside an effect or a client-only component — or give the server
path its own fs-based loader.
Why not just use an absolute path like /wasm/module.wasm?
It works for one deployment and breaks for the next — sub-path hosting, CDN offloading, embedding in another
site. The relative form adapts to all of them.
Related
- Emitting ES modules from Emscripten — Emscripten’s use of the same pattern.
- Loading Wasm in a Web Worker with ESM — resolution inside workers.
- Versioning Wasm files with content hashes — the caching benefit in detail.
- Publishing a Wasm package to npm — making the pattern work for library consumers.
← Back to ESM Bindings & Module Generation