diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -10,9 +10,7 @@
 
 ```ts
 import { area, Circle, largest, Rect, scale } from "shape";
-import { init } from "shape/runtime";
 
-await init(wasmUrl);
 area(Rect({ width: 3, height: 4 }));  // 12
 scale(2, Circle(1));                  // { $tag: "Circle", contents: 2 }
 largest([]);                          // null
@@ -59,7 +57,7 @@
 - An argument that does not fit its type (an out-of-range integer, a `Char`
   that is not one code point, an unknown constructor, …) throws a `TypeError`
   before reaching Haskell.
-- An exception in Haskell throws a `HaskellError`.
+- An exception in Haskell throws a `HaskellError` (exported by the bindings).
 
 ## Usage
 
@@ -85,16 +83,11 @@
     "version": "0.1.0",
     "type": "module",
     "exports": {
-        ".": { "types": "./src/shape.d.ts", "default": "./src/shape.js" },
-        "./runtime": { "types": "./src/ghc_wasm_bindgen_runtime.d.ts", "default": "./src/ghc_wasm_bindgen_runtime.js" },
-        "./shape.wasm": "./src/shape.wasm"
+        ".": { "types": "./src/shape.d.ts", "default": "./src/shape.js" }
     }
 }
 ```
 
-Then import `shape`, `shape/runtime` (`init`, `HaskellError`) and
-`shape/shape.wasm`.
-
 ### `generate`: plain source files
 
 ```sh
@@ -111,6 +104,25 @@
 "$(wasm32-wasi-ghc --print-libdir)"/post-link.mjs -i shape.wasm -o out/ghc_wasm_jsffi.js
 wasm-opt -Oz shape.wasm -o out/shape.wasm
 ```
+
+`shape.wasm` must end up next to `shape.js`.
+
+## Loading
+
+`shape.js` loads `shape.wasm` from its own directory with a top-level
+`await`, so importing it is enough: the functions are ready to call. If the
+`.wasm` cannot be loaded, the import fails.
+
+This needs ES2022 modules (top-level `await`): current browsers, Node 20.16+
+or 22.3+, and bundlers targeting ES2022 or later. The `.wasm` is referenced as
+`new URL("./shape.wasm", import.meta.url)`:
+
+- webpack 5, Vite and Parcel copy it to the output on their own. With Vite,
+  exclude the package from dependency pre-bundling
+  (`optimizeDeps: { exclude: ["shape"] }`), which would break that reference
+  in development.
+- esbuild leaves the reference as is: copy `shape.wasm` next to the bundle
+  (the [example](example/) does this).
 
 ## Installation
 
diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -21,7 +21,8 @@
     , "          (ghc-wasm-meta) on PATH."
     , "generate  For plain source files: writes NameWasmBindings.hs and the Haskell"
     , "          runtime to --hs-out, NAME.js, NAME.d.ts and the JS runtime to --js-out."
-    , "          Compiling them is up to you."
+    , "          Compiling them is up to you; NAME.js loads NAME.wasm from its own"
+    , "          directory."
     , ""
     , "  MODULE.hs      module whose export list is the API (e.g. src/ShapeWasm.hs)"
     , "  --name NAME    package and file name (e.g. shape)"
@@ -56,7 +57,7 @@
         | null (getModule o) -> go rest (setModule f o) setModule getModule setName flags
         | otherwise -> Left ("unexpected argument " ++ f)
     name n
-      | n `elem` ["ghc_wasm_bindgen_runtime", "ghc_wasm_bindgen_internal", "ghc_wasm_jsffi"] = Left ("--name " ++ n ++ " would overwrite the runtime files")
+      | n `elem` ["ghc_wasm_bindgen_internal", "ghc_wasm_jsffi"] = Left ("--name " ++ n ++ " would overwrite the runtime files")
       | null n || not (all (\c -> isAlphaNum c || c `elem` ("_-" :: String)) n) = Left ("--name must be letters, digits, _ or -: " ++ show n)
       | otherwise = Right n
 
diff --git a/ghc-wasm-bindgen.cabal b/ghc-wasm-bindgen.cabal
--- a/ghc-wasm-bindgen.cabal
+++ b/ghc-wasm-bindgen.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               ghc-wasm-bindgen
-version:            0.2.0.0
+version:            0.3.0.0
 synopsis:           JavaScript/TypeScript bindings for GHC's WASM backend
 description:
     Generate JavaScript and TypeScript bindings for a pure Haskell module
@@ -22,8 +22,6 @@
 extra-doc-files:
     README.md
 extra-source-files:
-    runtime-js/ghc_wasm_bindgen_runtime.js
-    runtime-js/ghc_wasm_bindgen_runtime.d.ts
     runtime-js/ghc_wasm_bindgen_internal.js
     runtime-js/ghc_wasm_bindgen_internal.d.ts
     runtime-js/ghc_wasm_jsffi.d.ts
diff --git a/runtime-js/ghc_wasm_bindgen_internal.d.ts b/runtime-js/ghc_wasm_bindgen_internal.d.ts
--- a/runtime-js/ghc_wasm_bindgen_internal.d.ts
+++ b/runtime-js/ghc_wasm_bindgen_internal.d.ts
@@ -1,9 +1,10 @@
 // ghc-wasm-bindgen runtime internals, imported by the generated bindings.
 
-import type { Either } from "./ghc_wasm_bindgen_runtime";
+/** `Either e a` from Haskell. */
+export type Either<E, A> = { Left: E } | { Right: A };
 
-/** Instantiates the .wasm module; must resolve before any binding is called. */
-export function init(source: string | URL | BufferSource): Promise<void>;
+/** Instantiates the .wasm module; called by the generated module. */
+export function load(url: URL): Promise<void>;
 
 /** A Haskell exception raised during a call. */
 export class HaskellError extends Error {}
diff --git a/runtime-js/ghc_wasm_bindgen_internal.js b/runtime-js/ghc_wasm_bindgen_internal.js
--- a/runtime-js/ghc_wasm_bindgen_internal.js
+++ b/runtime-js/ghc_wasm_bindgen_internal.js
@@ -3,21 +3,13 @@
 import jsffiImports from "./ghc_wasm_jsffi.js";
 
 let wasm;
-let ready;
 
-export function init(source) {
-    return (ready ??= load(source));
-}
-
-async function load(source) {
-    if (typeof source === "string" || source instanceof URL) {
-        const res = await fetch(source);
-        if (!res.ok) throw new Error(`ghc-wasm-bindgen: fetching ${source} failed (${res.status})`);
-        source = await res.arrayBuffer();
-    }
+// Instantiates the .wasm module. Called once, by the generated module's
+// top-level await, so the bindings are ready as soon as it is imported.
+export async function load(url) {
     // jsffiImports needs the exports, which exist only after instantiation.
     const knot = {};
-    const { instance } = await WebAssembly.instantiate(source, {
+    const { instance } = await WebAssembly.instantiate(await read(url), {
         ghc_wasm_jsffi: jsffiImports(knot),
         wasi_snapshot_preview1: wasi(knot),
     });
@@ -26,6 +18,16 @@
     wasm = knot;
 }
 
+// Node's fetch does not read file: URLs. getBuiltinModule (rather than an
+// import) keeps bundlers from trying to resolve node:fs for the browser.
+async function read(url) {
+    const fs = url.protocol === "file:" && globalThis.process?.getBuiltinModule?.("node:fs/promises");
+    if (fs) return fs.readFile(url);
+    const res = await fetch(url);
+    if (!res.ok) throw new Error(`ghc-wasm-bindgen: fetching ${url} failed (${res.status})`);
+    return res.arrayBuffer();
+}
+
 // WASI subset the RTS uses for pure code; other calls return ENOSYS.
 function wasi(exports) {
     const view = () => new DataView(exports.memory.buffer);
@@ -72,7 +74,6 @@
 
 // __ghcWasmBindgenError is set by GhcWasmBindgen.Runtime.guarded.
 export function call(fn, ...args) {
-    if (!wasm) throw new Error("ghc-wasm-bindgen: await init() before calling the API");
     const result = wasm[fn](...args);
     const err = wasm.__ghcWasmBindgenError;
     if (err !== undefined) {
diff --git a/runtime-js/ghc_wasm_bindgen_runtime.d.ts b/runtime-js/ghc_wasm_bindgen_runtime.d.ts
deleted file mode 100644
--- a/runtime-js/ghc_wasm_bindgen_runtime.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// Public interface of the ghc-wasm-bindgen runtime.
-
-export { init, HaskellError } from "./ghc_wasm_bindgen_internal";
-
-export type Either<E, A> = { Left: E } | { Right: A };
diff --git a/runtime-js/ghc_wasm_bindgen_runtime.js b/runtime-js/ghc_wasm_bindgen_runtime.js
deleted file mode 100644
--- a/runtime-js/ghc_wasm_bindgen_runtime.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// Public interface of the ghc-wasm-bindgen runtime.
-
-export { init, HaskellError } from "./ghc_wasm_bindgen_internal.js";
diff --git a/src/GhcWasmBindgen.hs b/src/GhcWasmBindgen.hs
--- a/src/GhcWasmBindgen.hs
+++ b/src/GhcWasmBindgen.hs
@@ -27,6 +27,7 @@
 import Data.FileEmbed (embedFile)
 import Data.List (isSuffixOf, stripPrefix)
 import Data.Maybe (mapMaybe)
+import qualified Data.Text as T
 import qualified Data.Text.Encoding as TE
 import System.Directory
 import System.Exit (ExitCode (..))
@@ -149,10 +150,8 @@
 -- | The JS bindings and runtime, by file name.
 jsFiles :: String -> Api -> [(FilePath, ByteString)]
 jsFiles name api =
-  [ (name <.> "js", TE.encodeUtf8 (emitJs api))
+  [ (name <.> "js", TE.encodeUtf8 (emitJs (T.pack name) api))
   , (name <.> "d.ts", TE.encodeUtf8 (emitDts api))
-  , ("ghc_wasm_bindgen_runtime.js", $(embedFile "runtime-js/ghc_wasm_bindgen_runtime.js"))
-  , ("ghc_wasm_bindgen_runtime.d.ts", $(embedFile "runtime-js/ghc_wasm_bindgen_runtime.d.ts"))
   , ("ghc_wasm_bindgen_internal.js", $(embedFile "runtime-js/ghc_wasm_bindgen_internal.js"))
   , ("ghc_wasm_bindgen_internal.d.ts", $(embedFile "runtime-js/ghc_wasm_bindgen_internal.d.ts"))
   , ("ghc_wasm_jsffi.d.ts", $(embedFile "runtime-js/ghc_wasm_jsffi.d.ts"))
diff --git a/src/GhcWasmBindgen/EmitJavaScript.hs b/src/GhcWasmBindgen/EmitJavaScript.hs
--- a/src/GhcWasmBindgen/EmitJavaScript.hs
+++ b/src/GhcWasmBindgen/EmitJavaScript.hs
@@ -1,6 +1,6 @@
--- | Emits the JS side of the bindings: @NAME.js@ (constructors, an
--- encode/decode function per type, the API functions) and @NAME.d.ts@ (their
--- TypeScript types).
+-- | Emits the JS side of the bindings: @NAME.js@ (loading of @NAME.wasm@,
+-- constructors, an encode/decode function per type, the API functions) and
+-- @NAME.d.ts@ (their TypeScript types).
 --
 -- The generated code never sees how values cross to Haskell: it converts
 -- them with the encode/decode functions of ghc_wasm_bindgen_internal.js, which
@@ -16,19 +16,35 @@
 header :: Api -> Text
 header api = "// Generated by ghc-wasm-bindgen from " <> apiModule api <> ". Do not edit."
 
--- | @NAME.js@
-emitJs :: Api -> Text
-emitJs api =
+-- | @NAME.js@. Loads @NAME.wasm@, found next to it, with a top-level await:
+-- importing the module is enough to use it.
+emitJs :: Text -> Api -> Text
+emitJs name api =
   T.unlines $
-    [header api, "", "import * as $rt from \"./ghc_wasm_bindgen_internal.js\";", ""]
+    [ header api
+    , ""
+    , "import * as $rt from \"./ghc_wasm_bindgen_internal.js\";"
+    , ""
+    , "export { HaskellError } from \"./ghc_wasm_bindgen_internal.js\";"
+    , ""
+    , "await $rt.load(new URL(" <> quote ("./" <> name <> ".wasm") <> ", import.meta.url));"
+    , ""
+    ]
       ++ concatMap jsType (apiTypes api)
       ++ intercalate [""] (map jsFunction (apiFns api))
 
--- | @NAME.d.ts@
+-- | @NAME.d.ts@. Its imports name the @.js@ file, as @node16@/@nodenext@
+-- module resolution requires (and @bundler@ accepts).
 emitDts :: Api -> Text
 emitDts api =
   T.unlines $
-    [header api, "", "import type * as $rt from \"./ghc_wasm_bindgen_runtime\";", ""]
+    [ header api
+    , ""
+    , "import type * as $rt from \"./ghc_wasm_bindgen_internal.js\";"
+    , ""
+    , "export { HaskellError, type Either } from \"./ghc_wasm_bindgen_internal.js\";"
+    , ""
+    ]
       ++ concatMap dtsType (apiTypes api)
       ++ map dtsFunction (apiFns api)
 
