Importing JavaScript Modules into Rust

This page answers one task: Rust code compiled with wasm-bindgen needs to call JavaScript that is not a Web API — a helper you wrote, a function from an npm package, a class with its own state — and the generated package must ship and resolve that JavaScript correctly.

Prerequisites

Imports beyond web-sys

web-sys and js-sys cover the browser’s built-in APIs and JavaScript’s standard library. Real projects also need their own JavaScript: a helper that wraps a third-party library, a function that is simpler to write in JavaScript, a class that holds DOM state. wasm-bindgen imports these the same way it imports built-ins — an extern "C" block of declarations — with one extra attribute saying which module they come from.

How the module is located depends on the attribute. module = "/path" (leading slash) refers to a file in your crate, which wasm-bindgen copies into the output as a snippet and imports by relative path. module = "package-name" (no slash) is emitted as a bare import that the bundler resolves from node_modules. inline_js embeds a string of JavaScript as a snippet. raw_module emits the specifier exactly as written, for advanced cases.

How each import attribute is resolved A module path starting with a slash is a file in the crate, copied into the output as a snippet. A bare name is left as a package import for the bundler. inline_js embeds a string as a snippet. raw_module emits the specifier unchanged. attribute source appears in output as module = "/js/helpers.js" file in the crate ./snippets/<crate-hash>/js/helpers.js module = "idb-keyval" npm package import … from "idb-keyval" inline_js = "export …" string in Rust source ./snippets/<crate-hash>/inline0.js raw_module = "../x.js" anything specifier unchanged

Step 1 — import a local helper file

Write the helper as an ES module inside the crate:

// js/format.js  (path relative to the crate root)
export function formatBytes(n) {
  const units = ["B", "KB", "MB", "GB"];
  let i = 0;
  while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
  return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
}

Declare it in Rust with a path starting at the crate root:

#[wasm_bindgen(module = "/js/format.js")]
extern "C" {
    #[wasm_bindgen(js_name = formatBytes)]
    fn format_bytes(n: f64) -> String;
}

#[wasm_bindgen]
pub fn describe(len: usize) -> String {
    format!("Payload: {}", format_bytes(len as f64))
}

On build, wasm-bindgen copies js/format.js into pkg/snippets/<crate-name>-<hash>/js/format.js and the generated glue imports it from there. The package is self-contained; consumers need nothing extra. Because the copy happens at build time, editing the helper requires a rebuild of the package before the change is visible, which is worth wiring into the development watcher.

Step 2 — import from an npm package

For a dependency of the JavaScript application, use the bare package name:

#[wasm_bindgen(module = "idb-keyval")]
extern "C" {
    #[wasm_bindgen(catch)]
    async fn get(key: &str) -> Result<JsValue, JsValue>;
    #[wasm_bindgen(catch)]
    async fn set(key: &str, value: JsValue) -> Result<JsValue, JsValue>;
}

The glue then contains import { get, set } from "idb-keyval", which only a bundler (or an import map) can resolve. Add the package as a dependency of the consuming application, or list it in the generated package.json — wasm-pack does not do that automatically. async fn declarations return Promises that Rust awaits as futures, as in awaiting JavaScript promises from Rust.

Step 3 — import classes, statics and default exports

Classes are declared as extern types with constructors and methods:

#[wasm_bindgen(module = "/js/chart.js")]
extern "C" {
    pub type Chart;

    #[wasm_bindgen(constructor)]
    fn new(canvas: &web_sys::HtmlCanvasElement) -> Chart;

    #[wasm_bindgen(method)]
    fn plot(this: &Chart, xs: &[f64], ys: &[f64]);

    #[wasm_bindgen(static_method_of = Chart, js_name = defaultTheme)]
    fn default_theme() -> JsValue;
}

For a module’s default export, bind the name default with js_name:

#[wasm_bindgen(module = "/js/legacy.js")]
extern "C" {
    #[wasm_bindgen(js_name = default)]
    fn legacy_init(config: &JsValue);
}

Step 4 — use inline_js for tiny helpers

For a few lines of JavaScript that do not deserve a file, embed them:

#[wasm_bindgen(inline_js = "export function now_ms() { return performance.now(); }")]
extern "C" {
    fn now_ms() -> f64;
}

The string becomes a snippet file like any other. Keep inline JavaScript short; anything longer is easier to read, lint and test as a real file.

From an extern block to a shipped snippet The extern block names a module path. wasm-bindgen copies the file into the snippets directory of the output package and generates an import in the glue. The bundler or browser resolves that relative import when the package is loaded. module = "/js/format.js" in Rust source wasm-bindgen copies the file pkg/snippets/…/ format.js shipped with pkg glue import ./snippets/…/format. js bundler or browser resolves relative path

Step 5 — make it work in every target

Snippets are ES modules, which fits --target web and --target bundler. --target nodejs (CommonJS) has limitations with snippets in some versions; prefer the experimental-nodejs-module target or web with Node’s ESM support. --target no-modules cannot use module imports at all, because its output is a plain script — keep helpers in a separate global script for that target, or move off it. Bare package imports require a bundler, an import map, or Deno’s npm specifiers. Test the package in each environment you ship to, as described in targeting Node and browsers from one Wasm package.

Choosing between importing and passing in

Importing a module hard-wires the dependency into the package: the Rust code always calls that helper. Sometimes the caller should decide instead — a logging function, a storage backend, a renderer that differs between the browser and tests. For those, accept the JavaScript dependency as a parameter or in an initialisation call: an object with methods (typed as an extern type), or a callback. The application constructs it and passes it in, and the Rust code calls its methods through wasm-bindgen exactly as it would call an imported function. That keeps the Wasm package free of environment-specific imports, makes it easy to substitute fakes in tests, and lets one build work in a browser, a worker and Node. Imports remain the better choice for helpers that are truly part of the library — formatting, small algorithms, wrappers that exist only to make a JavaScript library callable from Rust — because they need no setup from the caller and are always present.

Keeping the JavaScript side healthy

JavaScript files inside a Rust crate are easy to neglect: they are not checked by cargo, and type errors surface only when the Rust code calls them. Give them the same treatment as any JavaScript. Add a small package.json and tsconfig.json in the crate’s js/ directory, write the helpers in TypeScript or with JSDoc types and check them with tsc --noEmit in CI, lint them, and unit-test them with Vitest without involving Wasm at all. Keep the boundary narrow: one or two helper modules with a handful of functions each, rather than many scattered snippets, so the contract between Rust and JavaScript is easy to review. When a helper grows into a substantial piece of logic, consider whether it belongs in the JavaScript application instead, passed into Rust as a callback or an object, rather than being bundled into the Wasm package where its dependencies and versioning become the crate’s responsibility. And remember that every import is a boundary crossing: helpers called in hot loops should do enough work per call to amortise it.

Expected output

describe(5_300_000) returns "Payload: 5.1 MB"; pkg/snippets/ contains js/format.js; the application bundles the package with Vite without extra configuration; and calling Chart.new(canvas).plot(xs, ys) from Rust draws through the JavaScript class.

Gotchas

  • Path without a leading slash. module = "js/format.js" is treated as a package name. Use /js/format.js for crate files.
  • Bare imports without a bundler. import "idb-keyval" fails in a browser without an import map. Bundle or map it.
  • Missing dependency. wasm-pack does not add imported npm packages to package.json. Add them to the consumer.
  • CommonJS snippets. Snippets must be ES modules. Write export, not module.exports.
  • Helpers drifting from their Rust declarations. A renamed JavaScript export fails only at load time. Type-check the helpers.
  • no-modules target. It cannot import modules. Use a different target.

Performance note

A call from Rust to an imported JavaScript function cost about 10–20 ns in Chrome for numeric arguments — the same as a web-sys call. Passing a string added encoding cost proportional to its length. Snippets add their own size to the package, uncompressed, and are fetched as separate modules unless bundled.

Cost of one call from Rust into imported JavaScript Nanoseconds per call from Rust into an imported JavaScript function, with two numbers, with a short string, and with a 1 KB string. ns per call two f64 arguments 14 ns 16-character string 90 ns 1 KB string 1,100 ns

Frequently Asked Questions

Can I import TypeScript files directly? Not with module = "/…" — the snippet is shipped as-is. Compile TypeScript to JavaScript first, or import a package that ships JavaScript.

Where does the snippet hash come from? It identifies the crate so snippets from different crates and versions cannot collide.

Can two crates import the same npm package? Yes. The bundler deduplicates the bare import like any other.

Can imported JavaScript call back into Rust? Yes — pass a Closure or export Rust functions and import the generated module from the helper; see passing closures between Rust and JavaScript.

Does inline_js work with --target web? Yes. It becomes a snippet file imported by relative path, like a module file.

What happens if the imported function throws? Without catch, the exception propagates through the Wasm frames to the JavaScript caller. With #[wasm_bindgen(catch)], Rust receives it as Err(JsValue).

Can I import a JSON file? Not directly as a module import in every bundler. Wrap it in a small JavaScript module that imports or fetches it and exports the data.

← Back to wasm-bindgen Deep Dive