diff --git a/libraries/ghci/GHCi/ObjLink.hs b/libraries/ghci/GHCi/ObjLink.hs
index 1875918a9210ea5ac126a810fe7266eb5e37ecc6..4f63aa2f9b5a6a598b2db40fbb247975fcb10906 100644
--- a/libraries/ghci/GHCi/ObjLink.hs
+++ b/libraries/ghci/GHCi/ObjLink.hs
@@ -76,6 +76,9 @@ loadDLL f =
       evaluate =<< js_loadDLL (toJSString f)
       pure $ Right nullPtr
 
+-- See Note [Variable passing in JSFFI] for where
+-- __ghc_wasm_jsffi_dyld comes from
+
 foreign import javascript safe "__ghc_wasm_jsffi_dyld.loadDLL($1)"
   js_loadDLL :: JSString -> IO ()
 
diff --git a/utils/jsffi/dyld.mjs b/utils/jsffi/dyld.mjs
index c6cfedbece4f60075ebf4cb66929e1c2ed68ce29..db9c8b382d6c09e38d7bf6be252e05cf09c13bb0 100755
--- a/utils/jsffi/dyld.mjs
+++ b/utils/jsffi/dyld.mjs
@@ -629,7 +629,8 @@ class DyLD {
 
       // Fulfill the ghc_wasm_jsffi imports. Use new Function()
       // instead of eval() to prevent bindings in this local scope to
-      // be accessed by JSFFI code snippets.
+      // be accessed by JSFFI code snippets. See Note [Variable passing in JSFFI]
+      // for what's going on here.
       Object.assign(
         import_obj.ghc_wasm_jsffi,
         new Function(
diff --git a/utils/jsffi/post-link.mjs b/utils/jsffi/post-link.mjs
index 9e3e06435127ffc99f2bf01eb622c569053ba0af..b5493f35817d447b388f1fd3e4f04082171a070e 100755
--- a/utils/jsffi/post-link.mjs
+++ b/utils/jsffi/post-link.mjs
@@ -52,6 +52,47 @@ export function parseSections(mod) {
   return recs;
 }
 
+// Note [Variable passing in JSFFI]
+// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+//
+// The JSFFI code snippets can access variables in globalThis,
+// arguments like $1, $2, etc, plus a few magic variables: __exports,
+// __ghc_wasm_jsffi_dyld and __ghc_wasm_jsffi_finalization_registry.
+// How are these variables passed to JSFFI code? Remember, we strive
+// to keep the globalThis namespace hygiene and maintain the ability
+// to have multiple Haskell-wasm apps coexisting in the same JS
+// context, so we must not pass magic variables as global variables
+// even though they may seem globally unique.
+//
+// The solution is simple: put them in the JS lambda binder position.
+// Though there are different layers of lambdas here:
+//
+// 1. User writes "$1($2, await $3)" in a JSFFI code snippet. No
+//    explicit binder here, the snippet is either an expression or
+//    some statements.
+// 2. GHC doesn't know JS syntax but it knows JS function arity from
+//    HS type signature, as well as if the JS function is async/sync
+//    from safe/unsafe annotation. So it infers the JS binder (like
+//    "async ($1, $2, $3)") and emits a (name,binder,body) tuple into
+//    the ghc_wasm_jsffi custom section.
+// 3. After link-time we collect these tuples to make a JS object
+//    mapping names to binder=>body, and this JS object will be used
+//    to fulfill the ghc_wasm_jsffi wasm imports. This JS object is
+//    returned by an outer layer of lambda which is in charge of
+//    passing magic variables.
+//
+// In case of post-linker for statically linked wasm modules,
+// __ghc_wasm_jsffi_dyld won't work so is omitted, and
+// __ghc_wasm_jsffi_finalization_registry can be created inside the
+// outer JS lambda. Only __exports is exposed as user-visible API
+// since it's up to the user to perform knot-tying by assigning the
+// instance exports back to the (initially empty) __exports object
+// passed to this lambda.
+//
+// In case of dyld, all magic variables are dyld-session-global
+// variables; dyld uses new Function() to make the outer lambda, then
+// immediately invokes it by passing the right magic variables.
+
 export async function postLink(mod) {
   let src = (
     await fs.readFile(path.join(import.meta.dirname, "prelude.mjs"), {