diff --git a/hadrian/src/Base.hs b/hadrian/src/Base.hs
index b12b717f66ed984489fd04f62ce95a23290397c2..98c64ce1edd0d5aa4884b32055373af41f90f23d 100644
--- a/hadrian/src/Base.hs
+++ b/hadrian/src/Base.hs
@@ -154,7 +154,7 @@ ghcLibDeps stage iplace = do
         , "ghc-usage.txt"
         , "ghci-usage.txt"
         , "post-link.mjs"
-        , "prelude.js"
+        , "prelude.mjs"
         ]
     cxxStdLib <- systemCxxStdLibConfPath (PackageDbLoc stage iplace)
     return (cxxStdLib : ps)
diff --git a/hadrian/src/Rules/Generate.hs b/hadrian/src/Rules/Generate.hs
index 5fde5ca742de943eebf8bf91b307198f25613242..7c5b3452753bd8bda56f63242602fd83d9d5eaab 100644
--- a/hadrian/src/Rules/Generate.hs
+++ b/hadrian/src/Rules/Generate.hs
@@ -215,7 +215,7 @@ copyRules = do
             copyFile ("utils/jsffi" -/- makeRelative prefix file) file
             makeExecutable file
 
-        prefix -/- "prelude.js"        <~ pure "utils/jsffi"
+        prefix -/- "prelude.mjs"       <~ pure "utils/jsffi"
 
         prefix -/- "html/**"           <~ return "utils/haddock/haddock-api/resources"
         prefix -/- "latex/**"          <~ return "utils/haddock/haddock-api/resources"
diff --git a/utils/jsffi/post-link.mjs b/utils/jsffi/post-link.mjs
index abd55622581b1ba6fe76a451e5ba35a5ec182cfb..5de39fdc4007ac2262b312de77b86aca8bcb769e 100755
--- a/utils/jsffi/post-link.mjs
+++ b/utils/jsffi/post-link.mjs
@@ -52,11 +52,14 @@ function parseSections(mod) {
   return recs;
 }
 
-
 export async function postLink(mod) {
-  let src = await fs.readFile(path.join(import.meta.dirname, "prelude.js"), {
-    encoding: "utf-8",
-  });
+  let src = (
+    await fs.readFile(path.join(import.meta.dirname, "prelude.mjs"), {
+      encoding: "utf-8",
+    })
+  ).replaceAll("export ", ""); // we only use it as code template, don't export stuff
+
+  // Keep this in sync with dyld.mjs!
   src = `${src}\nexport default (__exports) => {`;
   src = `${src}\nconst __ghc_wasm_jsffi_jsval_manager = new JSValManager();`;
   src = `${src}\nconst __ghc_wasm_jsffi_finalization_registry = new FinalizationRegistry(sp => __exports.rts_freeStablePtr(sp));`;
diff --git a/utils/jsffi/prelude.js b/utils/jsffi/prelude.mjs
similarity index 63%
rename from utils/jsffi/prelude.js
rename to utils/jsffi/prelude.mjs
index e54a2927901330faa972faba5977b3105ef2ea0f..c9cd6a447d482d8fec202099df5ba947f809d792 100644
--- a/utils/jsffi/prelude.js
+++ b/utils/jsffi/prelude.mjs
@@ -5,7 +5,7 @@
 
 // Manage a mapping from unique 32-bit ids to actual JavaScript
 // values.
-class JSValManager {
+export class JSValManager {
   #lastk = 0;
   #kv = new Map();
 
@@ -49,47 +49,48 @@ class JSValManager {
   }
 }
 
-// A simple & fast setImmediate() implementation for browsers. It's
-// not a drop-in replacement for node.js setImmediate() because:
-// 1. There's no clearImmediate(), and setImmediate() doesn't return
-//    anything
-// 2. There's no guarantee that callbacks scheduled by setImmediate()
-//    are executed in the same order (in fact it's the opposite lol),
-//    but you are never supposed to rely on this assumption anyway
-class SetImmediate {
-  #fs = [];
-  #mc = new MessageChannel();
-
-  constructor() {
-    this.#mc.port1.addEventListener("message", () => {
-      this.#fs.pop()();
-    });
-    this.#mc.port1.start();
-  }
-
-  setImmediate(cb, ...args) {
-    this.#fs.push(() => cb(...args));
-    this.#mc.port2.postMessage(undefined);
-  }
-}
-
 // The actual setImmediate() to be used. This is a ESM module top
 // level binding and doesn't pollute the globalThis namespace.
 const setImmediate = await (async () => {
-  // https://developer.mozilla.org/en-US/docs/Web/API/Scheduler
-  if (globalThis.scheduler) {
-    return (cb, ...args) => scheduler.postTask(() => cb(...args));
-  }
   // node, bun, or other scripts might have set this up in the browser
   if (globalThis.setImmediate) {
     return globalThis.setImmediate;
   }
+
+  // deno
   try {
-    // deno
     return (await import("node:timers")).setImmediate;
-  } catch {
-    // browsers
-    const sm = new SetImmediate();
-    return (cb, ...args) => sm.setImmediate(cb, ...args);
+  } catch {}
+
+  // https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/postTask
+  if (globalThis.scheduler) {
+    return (cb, ...args) => scheduler.postTask(() => cb(...args));
+  }
+
+  // A simple & fast setImmediate() implementation for browsers. It's
+  // not a drop-in replacement for node.js setImmediate() because:
+  // 1. There's no clearImmediate(), and setImmediate() doesn't return
+  //    anything
+  // 2. There's no guarantee that callbacks scheduled by setImmediate()
+  //    are executed in the same order (in fact it's the opposite lol),
+  //    but you are never supposed to rely on this assumption anyway
+  class SetImmediate {
+    #fs = [];
+    #mc = new MessageChannel();
+
+    constructor() {
+      this.#mc.port1.addEventListener("message", () => {
+        this.#fs.pop()();
+      });
+      this.#mc.port1.start();
+    }
+
+    setImmediate(cb, ...args) {
+      this.#fs.push(() => cb(...args));
+      this.#mc.port2.postMessage(undefined);
+    }
   }
+
+  const sm = new SetImmediate();
+  return (cb, ...args) => sm.setImmediate(cb, ...args);
 })();