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 fetch or instantiateStreaming.
  • [ ] Module scripts — <script type="module">, a module worker, or a bundler that outputs ES modules. import.meta does 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.

What a relative path resolves against The same relative reference module.wasm resolves to different absolute URLs depending on whether it is passed to fetch directly or resolved against import.meta.url, for a page at /docs/guide/ loading a script from /static/js/. reference resolves against result fetch("module.wasm") page URL /docs/guide/ /docs/guide/module.wasm fetch("/module.wasm") site root /module.wasm (breaks on sub-paths) new URL("module.wasm", import.meta.url) script /static/js/app.js /static/js/module.wasm

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.

The import.meta.url pattern through a bundler The source references module.wasm relative to the script. The bundler detects the literal new URL expression, emits a hashed copy of the file, and rewrites the reference, so at runtime the browser fetches the hashed file from the asset directory. source new URL('./module.wasm', import.meta.url) bundler detects literal Vite, webpack 5, Rollup emits hashed copy assets/module-5d41a2c8.wasm runtime fetch absolute, cache-busted URL

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. Add type="module" to the tag, or { type: "module" } to the Worker constructor.
  • 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.url is a file: URL in Node, and fetch in older Node releases does not support file: URLs. Read the file with fs.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.

Returning-visit time to ready, hashed versus unhashed module URL A 900 KB module on a returning visit. The hashed, immutable URL is served from cache with no network round trip and its compiled code is reused; the unhashed URL needs a revalidation request first. ms until exports are callable hashed, immutable 46 ms unhashed, revalidated (304) 336 ms unhashed, no-cache header 1,140 ms

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.

← Back to ESM Bindings & Module Generation