diff --git a/docs/users_guide/wasm.rst b/docs/users_guide/wasm.rst
index db20aa8776f0a6fd6da2cb025ea6ec1fa262afb7..95804e473b257dcd23f3f2e967644a14d1aebe6f 100644
--- a/docs/users_guide/wasm.rst
+++ b/docs/users_guide/wasm.rst
@@ -189,9 +189,9 @@ use of ``freeJSVal`` when you’re sure about a ``JSVal``\ ’s lifetime,
 especially for the temporary ``JSVal``\ s. This will help reducing the
 memory footprint at runtime.
 
-Note that ``freeJSVal`` is not idempotent and it’s only safe to call it
-exactly once or not at all. Once it’s called, any subsequent usage of
-that ``JSVal`` results in a runtime panic.
+Note that ``freeJSVal`` is idempotent and it’s safe to call it more
+than once. After it’s called, any subsequent usage of that ``JSVal``
+by passing to the JavaScript side results in a runtime panic.
 
 .. _wasm-jsffi-import:
 
@@ -390,7 +390,7 @@ callback and intends to call it later, so the Haskell function closure
 is still retained by default.
 
 Still, the runtime can gradually drop these retainers by using
-``FinalizerRegistry`` to invoke the finalizers to free the underlying
+``FinalizationRegistry`` to invoke the finalizers to free the underlying
 stable pointers once the JavaScript callbacks are recycled.
 
 One last corner case is cyclic reference between the two heaps: if a
diff --git a/libraries/ghc-experimental/src/GHC/Wasm/Prim.hs b/libraries/ghc-experimental/src/GHC/Wasm/Prim.hs
index 480c2f4d7cc5e5c6951fe77967ab2a0a07ea879f..f488ed826ce7804cfd45fcab6ad556b4b9316b62 100644
--- a/libraries/ghc-experimental/src/GHC/Wasm/Prim.hs
+++ b/libraries/ghc-experimental/src/GHC/Wasm/Prim.hs
@@ -1,22 +1,21 @@
 {-# LANGUAGE NoImplicitPrelude #-}
 
 module GHC.Wasm.Prim (
-  -- User-facing JSVal type and freeJSVal
+  -- * User-facing 'JSVal' and related utilities
   JSVal,
   freeJSVal,
   mkWeakJSVal,
 
-  -- The JSString type and conversion from/to Haskell String
+  -- * 'JSString' and conversion from/to Haskell 'String'
   JSString (..),
   fromJSString,
   toJSString,
 
-  -- Exception types related to JSFFI
+  -- * Exception types related to JSFFI
   JSException (..),
   WouldBlockException (..),
-  PromisePendingException (..),
 
-  -- Is JSFFI used in the current wasm module?
+  -- * Is JSFFI used in the current wasm module?
   isJSFFIUsed
 ) where
 
diff --git a/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim.hs b/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim.hs
index ed74fbe8b3a12609e9a20bfe5cb7e42a902a5c78..c1426f053bde015c95a19c127edeeaf8bf075b16 100644
--- a/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim.hs
+++ b/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim.hs
@@ -1,22 +1,21 @@
 {-# LANGUAGE NoImplicitPrelude #-}
 
 module GHC.Internal.Wasm.Prim (
-  -- User-facing JSVal type and freeJSVal
+  -- * User-facing 'JSVal' and related utilities
   JSVal (..),
   freeJSVal,
   mkWeakJSVal,
 
-  -- The JSString type and conversion from/to Haskell String
+  -- * 'JSString' and conversion from/to Haskell 'String'
   JSString (..),
   fromJSString,
   toJSString,
 
-  -- Exception types related to JSFFI
+  -- * Exception types related to JSFFI
   JSException (..),
   WouldBlockException (..),
-  PromisePendingException (..),
 
-  -- Is JSFFI used in the current wasm module?
+  -- * Is JSFFI used in the current wasm module?
   isJSFFIUsed
 ) where
 
diff --git a/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Exports.hs b/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Exports.hs
index d71862c9a099c08cc47d0c7d4149c74913e18063..1c02795b92dfb8f1575cf9e42d4b82fb8a7382f8 100644
--- a/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Exports.hs
+++ b/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Exports.hs
@@ -43,10 +43,14 @@ import GHC.Internal.Word
 mkJSCallback :: (StablePtr a -> IO JSVal) -> a -> IO JSVal
 mkJSCallback adjustor f = do
   sp@(StablePtr sp#) <- newStablePtr f
-  JSVal v w _ <- adjustor sp
-  let r = JSVal v w sp#
-  js_callback_register r sp
-  pure r
+  v@(JSVal p) <- adjustor sp
+  IO $ \s0 -> case stg_setJSVALsp p sp# s0 of
+    (# s1 #) -> (# s1, () #)
+  js_callback_register v sp
+  pure v
+
+foreign import prim "stg_setJSVALsp"
+  stg_setJSVALsp :: JSVal# -> StablePtr# a -> State# RealWorld -> (# State# RealWorld #)
 
 foreign import javascript unsafe "__ghc_wasm_jsffi_finalization_registry.register($1, $2, $1)"
   js_callback_register :: JSVal -> StablePtr a -> IO ()
diff --git a/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Flag.hs b/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Flag.hs
index 44d272ab9c866813fe423c97d928ce0ddf0fcdb0..ef84f38fc071a062ee4fb93592b5d313f2609f84 100644
--- a/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Flag.hs
+++ b/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Flag.hs
@@ -7,4 +7,8 @@ where
 
 import GHC.Internal.Base
 
+-- | If the current wasm module has any JSFFI functionality linked in,
+-- this would be 'True' at runtime and 'False' otherwise. If this is
+-- 'False', the wasm module would be a self-contained wasm32-wasi
+-- module that can be run by non-web runtimes as well.
 foreign import ccall unsafe "rts_JSFFI_used" isJSFFIUsed :: Bool
diff --git a/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Types.hs b/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Types.hs
index 2b5f2e408dbb47e37723de653689f36af05a1fe5..d5e8348b6e69a4b60a680e1b4da9d31c9c4773c2 100644
--- a/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Types.hs
+++ b/libraries/ghc-internal/src/GHC/Internal/Wasm/Prim/Types.hs
@@ -1,7 +1,9 @@
 {-# LANGUAGE GHC2021 #-}
+{-# LANGUAGE GHCForeignImportPrim #-}
 {-# LANGUAGE MagicHash #-}
 {-# LANGUAGE UnboxedTuples #-}
 {-# LANGUAGE NoImplicitPrelude #-}
+{-# LANGUAGE UnliftedFFITypes #-}
 {-# LANGUAGE UnliftedNewtypes #-}
 
 module GHC.Internal.Wasm.Prim.Types (
@@ -26,7 +28,6 @@ import GHC.Internal.IO
 import GHC.Internal.IO.Encoding
 import GHC.Internal.Num
 import GHC.Internal.Show
-import GHC.Internal.Stable
 import GHC.Internal.Weak
 
 {-
@@ -38,76 +39,150 @@ On wasm, the Haskell heap lives in the linear memory space, and it can
 only contain bit patterns, not opaque references of the host
 JavaScript heap. As long as we have two heaps that coexist in this
 way, the best we can do is representing JavaScript references as
-unique ids in the Haskell heap.
-
-In JavaScript, we have a JSValManager which exposes some interfaces as
-wasm imports. The JSValManager is in charge of allocating unique ids
-and managing the mapping from ids to the actual JavaScript values. In
-fact we can implement the entire JSValManager in wasm, using a wasm
-table with externref elements to hold the JavaScript values and a
-special allocator to manage free slots in the table. That'll take more
-work to implement though, with one more caveat: browsers typically
-limit max wasm table size to 10000000 which may not be large enough
-for some use cases. We can workaround the table size restriction by
-managing a pool or tree of wasm tables, but at this point we really
-should ditch the idea of doing everything in wasm just because we can.
-
-Next, we have the unlifted JSVal# type, defined in jsval.cmm and
-contains one non-pointer word which is the id allocated by
-JSValManager. On top of JSVal#, we have the user-facing lifted JSVal
-type, which carries the JSVal#, as well as a weak pointer and a stable
-pointer.
-
-The weak pointer is used to garbage collect JSVals. Its key is the
-JSVal# closure, and it has a C finalizer that tells the JSValManager
-to drop the mapping when the JSVal# closure is collected. Since we
-want to provide freeJSVal to allow eager freeing of JSVals, we need to
-carry it as a field of JSVal.
-
-The stable pointer field is NULL for normal JSVals created via foreign
-import results or foreign export arguments. But for JSFFI dynamic
-exports that wraps a Haskell function closure as a JavaScript callback
-and returns that callback's JSVal, it is a stable pointer that pins
-that Haskell function closure. If this JSVal is garbage collected,
-then we can only rely on a JavaScript FinalizerRegistry to free the
-stable pointer in the future, but if we eagerly free the callback with
-freeJSVal, then we can eagerly free this stable pointer as well.
-
-The lifted JSVal type is meant to be an abstract type. Its creation
-and consumption is mainly handled by the RTS API functions rts_mkJSVal
-and rts_getJSVal, which are used in C stub files generated when
-desugaring JSFFI foreign imports/exports.
+ids in the Haskell heap.
+
+First, we have the unlifted JSVal# type, defined in jsval.cmm with the
+following memory layout:
+
++--------------+-----+----+----------+
+|stg_JSVAL_info|Weak#|Int#|StablePtr#|
++--------------+-----+----+----------+
+
+The first non-pointer Int# field is a 32-bit id allocated and
+returned by the JSValManager on the JavaScript side. The JSValManager
+maintains a Map from ids to actual JavaScript values. This field is
+immutable throughout a JSVal# closure's lifetime and is unique for
+each JSVal# ever created.
+
+The Weak# poiner sets the JSVal# closure as key and has a C finalizer
+that drops the mapping in JSValManager. When the JSVal# closure is
+garbage collected, the finalizer is invoked, but it can also be
+eagerly invoked by freeJSVal, that's why we carry the Weak# in JSVal#
+as a pointer field.
+
+Normally, one JSVal# manage one kind of resource: the JavaScript value
+retained in JSValManager. However, in case of JSFFI exports where we
+convert Haskell functions to JavaScript callbacks, the JSVal# manages
+not only the callback on the JavaScript side, but also a stable
+pointer that pins the exported function on the Haskell side. That
+StablePtr# is recorded in the JSVal# closure.
+
+Even if the JSVal# closure is garbage collected, we don't know if the
+JavaScript side still retains the callback somewhere other than
+JSValManager, so the stable pointer will continue to pin the Haskell
+function closure. We do a best effort cleanup on the JavaScript side
+by using a FinalizationRegistry: if the JSVal# is automatically
+collected, the callback is dropped in JSValManager and also not used
+elsewhere, the FinalizationRegistry calls into the RTS to drop the
+stable pointer as well.
+
+However, JSVal# can be eagerly freed by freeJSVal. It'll deregister
+the callback in the FinalizationRegistry, finalize the Weak# pointer
+and also free the stable pointer. In order to make freeJSVal
+idempotent, we must not free the stable pointer twice; therefore the
+StablePtr# field is mutable and will be overwritten with NULL upon
+first freeJSVal invocation; it's also NULL upon creation by
+rts_mkJSVal and later overwritten with the StablePtr# upon the
+callback creation.
+
+On top of JSVal#, we have the user-facing lifted JSVal type, which
+wraps the JSVal#. The lifted JSVal type is meant to be an abstract
+type. Its creation and consumption is mainly handled by the RTS API
+functions rts_mkJSVal and rts_getJSVal, which are used in C stub files
+generated when desugaring JSFFI foreign imports/exports.
 
 -}
 
 newtype JSVal#
   = JSVal# (Any :: UnliftedType)
 
+-- | A 'JSVal' is a first-class Haskell value on the Haskell heap that
+-- represents a JavaScript value. You can use 'JSVal' or its @newtype@
+-- as a supported argument or result type in JSFFI import & export
+-- declarations, in addition to those lifted FFI types like 'Int' or
+-- 'Ptr' that's already supported by C FFI. It is garbage collected by
+-- the GHC RTS:
+--
+-- * There can be different 'JSVal's that point to the same JavaScript
+--   value. As long as there's at least one 'JSVal' still alive on the
+--   Haskell heap, that JavaScript value will still be alive on the
+--   JavaScript heap.
+-- * If there's no longer any live 'JSVal' that points to the
+--   JavaScript value, after Haskell garbage collection, the
+--   JavaScript runtime will be able to eventually garbage collect
+--   that JavaScript value as well.
+--
+-- There's a special kind of 'JSVal' that represents a JavaScript
+-- callback exported from a Haskell function like this:
+--
+-- > foreign import javascript "wrapper"
+-- >   exportFibAsAsyncJSCallback :: (Int -> Int) -> IO JSVal
+--
+-- Such a 'JSVal' manages an additional kind of resource: the exported
+-- Haskell function closure. Even if it is automatically garbage
+-- collected, the Haskell function closure would still be retained
+-- since the JavaScript callback might be retained elsewhere. We do a
+-- best-effort collection here using JavaScript
+-- [@FinalizationRegistry@](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry),
+-- so the Haskell function closure might be eventually dropped if the
+-- JavaScript callback is garbage collected.
+--
+-- Note that even the @FinalizationRegistry@ logic can't break cyclic
+-- references between the Haskell/JavaScript heap: when an exported
+-- Haskell function closure retains a 'JSVal' that represents a
+-- JavaScript callback. Though this can be solved by explicit
+-- 'freeJSVal' calls.
 data JSVal
-  = forall a . JSVal JSVal# (Weak# JSVal) (StablePtr# a)
+  = JSVal JSVal#
 
+-- | 'freeJSVal' eagerly frees a 'JSVal' in the runtime. It drops the
+-- retained JavaScript value on the JavaScript side, and in case of a
+-- 'JSVal' that represents a callback, also drops the retained Haskell
+-- function closure. Once a 'JSVal' is freed by 'freeJSVal', later
+-- attempts to pass it to the JavaScript side would result in runtime
+-- crashes, so you should only call 'freeJSVal' when you're confident
+-- that 'JSVal' won't be used again (and in case of callbacks, that
+-- callback won't be invoked again).
+--
+-- 'freeJSVal' is idempotent: it's safe to call it more than once on
+-- the same 'JSVal', subsequent invocations are no-ops. You are
+-- strongly recommended to call 'freeJSVal' on short-lived
+-- intermediate 'JSVal' values for timely release of resources!
 freeJSVal :: JSVal -> IO ()
-freeJSVal v@(JSVal _ w sp) = do
-  case sp `eqStablePtr#` unsafeCoerce# nullAddr# of
-    0# -> do
-      js_callback_unregister v
-      freeStablePtr $ StablePtr sp
-    _ -> pure ()
-  IO $ \s0 -> case finalizeWeak# w s0 of
+freeJSVal v@(JSVal p) = do
+  js_callback_unregister v
+  IO $ \s0 -> case stg_freeJSVal# p s0 of
     (# s1, _, _ #) -> (# s1, () #)
 
+-- | 'mkWeakJSVal' allows you to create a 'Weak' pointer that observes
+-- the liveliness of a 'JSVal' closure on the Haskell heap and
+-- optionally attach a finalizer.
+--
+-- Note that this liveliness is not affected by 'freeJSVal': even if
+-- 'freeJSVal' is called, the 'JSVal' might still be alive on the
+-- Haskell heap as a dangling reference and 'deRefWeak' might still be
+-- able to retrieve the 'JSVal' before it is garbage collected.
 mkWeakJSVal :: JSVal -> Maybe (IO ()) -> IO (Weak JSVal)
-mkWeakJSVal v@(JSVal k _ _) (Just (IO fin)) = IO $ \s0 ->
-  case mkWeak# k v fin s0 of
+mkWeakJSVal v@(JSVal p) (Just (IO fin)) = IO $ \s0 ->
+  case mkWeak# p v fin s0 of
     (# s1, w #) -> (# s1, Weak w #)
-mkWeakJSVal (JSVal _ w _) Nothing = pure $ Weak w
+mkWeakJSVal v@(JSVal p) Nothing = IO $ \s0 ->
+  case mkWeakNoFinalizer# p v s0 of
+    (# s1, w #) -> (# s1, Weak w #)
+
+foreign import prim "stg_freeJSVAL"
+  stg_freeJSVal# :: JSVal# -> State# RealWorld -> (# State# RealWorld, Int#, State# RealWorld -> (# State# RealWorld, b #) #)
 
-foreign import javascript unsafe "if (!__ghc_wasm_jsffi_finalization_registry.unregister($1)) { throw new WebAssembly.RuntimeError('js_callback_unregister'); }"
+foreign import javascript unsafe "try { __ghc_wasm_jsffi_finalization_registry.unregister($1); } catch {}"
   js_callback_unregister :: JSVal -> IO ()
 
+-- | A 'JSString' represents a JavaScript string.
 newtype JSString
   = JSString JSVal
 
+-- | Converts a 'JSString' to a Haskell 'String'. Conversion is done
+-- eagerly once the resulting 'String' is forced, and the argument
+-- 'JSString' may be explicitly freed if no longer used.
 fromJSString :: JSString -> String
 fromJSString s = unsafeDupablePerformIO $ do
   l <- js_stringLength s
@@ -122,15 +197,25 @@ foreign import javascript unsafe "$1.length"
 foreign import javascript unsafe "(new TextEncoder()).encodeInto($1, new Uint8Array(__exports.memory.buffer, $2, $3)).written"
   js_encodeInto :: JSString -> Ptr a -> Int -> IO Int
 
+-- | Converts a Haskell 'String' to a 'JSString'.
 toJSString :: String -> JSString
 toJSString s = unsafeDupablePerformIO $ withCStringLen utf8 s $ \(buf, len) -> js_toJSString buf len
 
 foreign import javascript unsafe "(new TextDecoder('utf-8', {fatal: true})).decode(new Uint8Array(__exports.memory.buffer, $1, $2))"
   js_toJSString :: Ptr a -> Int -> IO JSString
 
+-- | A 'JSException' represents a JavaScript exception. It is likely
+-- but not guaranteed to be an instance of the @Error@ class. When you
+-- call an async JSFFI import and the result @Promise@ rejected, the
+-- rejection value will be wrapped in a 'JSException' and re-thrown in
+-- Haskell once you force the result.
 newtype JSException
   = JSException JSVal
 
+-- | If the
+-- [@error.stack@](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/stack)
+-- property is present, it will be used to render the 'Show' instance
+-- output so you can see the JavaScript stack trace.
 instance Show JSException where
   showsPrec p e =
     showParen (p >= 11) $ showString "JSException " . showsPrec 11 (jsErrorString e)
@@ -147,6 +232,24 @@ foreign import javascript unsafe "`${$1.stack ? $1.stack : $1}`"
 
 instance Exception JSException
 
+-- | An async JSFFI import returns a thunk that represents a pending
+-- JavaScript @Promise@:
+--
+-- > foreign import javascript "(await fetch($1)).text()"
+-- >   js_fetch :: JSString -> IO JSString
+--
+-- Forcing that thunk blocks the current Haskell thread until the
+-- @Promise@ is fulfilled, but that cannot happen if the Haskell
+-- thread is a bound thread created by a JSFFI sync export or a C FFI
+-- export! Those Haskell computations are meant to return
+-- synchronously, but JavaScript asynchronocity is contagious and
+-- there's no escape hatch like @unsafeAwaitPromise@.
+--
+-- In such cases, a 'WouldBlockException' exception would be thrown.
+-- The 'WouldBlockException' is attached with a diagnostic message
+-- generated at compile-time (currently just the JSFFI source snippet
+-- of the corresponding async import) to help debugging the
+-- exception's cause.
 newtype WouldBlockException
   = WouldBlockException String
   deriving (Show)
diff --git a/rts/wasm/JSFFI.c b/rts/wasm/JSFFI.c
index ba824200021f3867a5e67ca3ad8d16e8ad09613e..4129591dcf8fe0385678a2375132ac3ab10ddfee 100644
--- a/rts/wasm/JSFFI.c
+++ b/rts/wasm/JSFFI.c
@@ -70,7 +70,7 @@ __attribute__((constructor(102))) static void __ghc_wasm_jsffi_init(void) {
 }
 
 typedef __externref_t HsJSVal;
-typedef StgWord JSValKey;
+typedef StgInt JSValKey;
 
 extern const StgInfoTable stg_JSVAL_info;
 extern const StgInfoTable ghczminternal_GHCziInternalziWasmziPrimziTypes_JSVal_con_info;
@@ -91,9 +91,10 @@ HaskellObj rts_mkJSVal(Capability*, HsJSVal);
 HaskellObj rts_mkJSVal(Capability *cap, HsJSVal v) {
   JSValKey k = __imported_newJSVal(v);
 
-  HaskellObj p = (HaskellObj)allocate(cap, CONSTR_sizeW(0, 1));
+  HaskellObj p = (HaskellObj)allocate(cap, CONSTR_sizeW(1, 2));
   SET_HDR(p, &stg_JSVAL_info, CCS_SYSTEM);
-  p->payload[0] = (HaskellObj)k;
+  p->payload[1] = (HaskellObj)k;
+  p->payload[2] = NULL;
 
   StgCFinalizerList *cfin =
       (StgCFinalizerList *)allocate(cap, sizeofW(StgCFinalizerList));
@@ -107,6 +108,7 @@ HaskellObj rts_mkJSVal(Capability *cap, HsJSVal v) {
   SET_HDR(w, &stg_WEAK_info, CCS_SYSTEM);
   w->cfinalizers = (StgClosure *)cfin;
   w->key = p;
+  w->value = Unit_closure;
   w->finalizer = &stg_NO_FINALIZER_closure;
   w->link = cap->weak_ptr_list_hd;
   cap->weak_ptr_list_hd = w;
@@ -114,14 +116,13 @@ HaskellObj rts_mkJSVal(Capability *cap, HsJSVal v) {
     cap->weak_ptr_list_tl = w;
   }
 
-  HaskellObj box = (HaskellObj)allocate(cap, CONSTR_sizeW(3, 0));
+  p->payload[0] = (HaskellObj)w;
+
+  HaskellObj box = (HaskellObj)allocate(cap, CONSTR_sizeW(1, 0));
   SET_HDR(box, &ghczminternal_GHCziInternalziWasmziPrimziTypes_JSVal_con_info, CCS_SYSTEM);
   box->payload[0] = p;
-  box->payload[1] = (HaskellObj)w;
-  box->payload[2] = NULL;
 
-  w->value = TAG_CLOSURE(1, box);
-  return w->value;
+  return TAG_CLOSURE(1, box);
 }
 
 __attribute__((import_module("ghc_wasm_jsffi"), import_name("getJSVal")))
@@ -129,7 +130,7 @@ HsJSVal __imported_getJSVal(JSValKey);
 
 STATIC_INLINE HsJSVal rts_getJSValzh(HaskellObj p) {
   ASSERT(p->header.info == &stg_JSVAL_info);
-  return __imported_getJSVal((JSValKey)p->payload[0]);
+  return __imported_getJSVal((JSValKey)p->payload[1]);
 }
 
 HsJSVal rts_getJSVal(HaskellObj);
diff --git a/rts/wasm/jsval.cmm b/rts/wasm/jsval.cmm
index f8feb85ed5efab5dadbf894586fc9597a98e6ee3..9665f2558e1d0aff64ace599e6b8211d59572735 100644
--- a/rts/wasm/jsval.cmm
+++ b/rts/wasm/jsval.cmm
@@ -1,10 +1,33 @@
 #include "Cmm.h"
 
-// This defines the unlifted JSVal# type. See Note [JSVal
-// representation for wasm] for detailed explanation.
+// This defines the unlifted JSVal# type. See
+// Note [JSVal representation for wasm] for
+// detailed explanation.
 
-INFO_TABLE(stg_JSVAL, 0, 1, PRIM, "JSVAL", "JSVAL")
+INFO_TABLE(stg_JSVAL, 1, 2, PRIM, "JSVAL", "JSVAL")
   (P_ node)
 {
   return (node);
 }
+
+stg_setJSVALsp (P_ p, W_ sp)
+{
+  W_[p + SIZEOF_StgHeader + WDS(2)] = sp;
+  return ();
+}
+
+stg_freeJSVAL (P_ p)
+{
+  P_ w;
+  W_ sp;
+
+  w = P_[p + SIZEOF_StgHeader];
+  sp = W_[p + SIZEOF_StgHeader + WDS(2)];
+
+  if (sp != NULL) {
+    ccall freeStablePtr(sp);
+    W_[p + SIZEOF_StgHeader + WDS(2)] = NULL;
+  }
+
+  jump stg_finalizzeWeakzh (w);
+}
diff --git a/testsuite/tests/jsffi/jsffigc.hs b/testsuite/tests/jsffi/jsffigc.hs
index 851ad0438d31a603e14bb94a88ba6e7b6ed55eb4..520d60c579d90c05656b2269260604e7981b588a 100644
--- a/testsuite/tests/jsffi/jsffigc.hs
+++ b/testsuite/tests/jsffi/jsffigc.hs
@@ -68,7 +68,7 @@ testDynExportGC x y z = do
   -- Return a continuation to be called after the JavaScript side
   -- finishes garbage collection.
   js_mk_cont $ do
-    -- The JavaScript FinalizerRegistry logic only frees the stable
+    -- The JavaScript FinalizationRegistry logic only frees the stable
     -- pointer that pins fn. So we need to invoke Haskell garbage
     -- collection again.
     performGC
diff --git a/utils/jsffi/prelude.mjs b/utils/jsffi/prelude.mjs
index f502f2040bd7a0221c51270cec6b6525de51b830..91015283caa7562bdb574e78ced468fde57bf4c9 100644
--- a/utils/jsffi/prelude.mjs
+++ b/utils/jsffi/prelude.mjs
@@ -3,29 +3,13 @@
 // of one; the post-linker script will copy all contents into a new
 // ESM module.
 
-// Manage a mapping from unique 32-bit ids to actual JavaScript
-// values.
+// Manage a mapping from 32-bit ids to actual JavaScript values.
 export class JSValManager {
   #lastk = 0;
   #kv = new Map();
 
-  constructor() {}
-
-  // Maybe just bump this.#lastk? For 64-bit ids that's sufficient,
-  // but better safe than sorry in the 32-bit case.
-  #allocKey() {
-    let k = this.#lastk;
-    while (true) {
-      if (!this.#kv.has(k)) {
-        this.#lastk = k;
-        return k;
-      }
-      k = (k + 1) | 0;
-    }
-  }
-
   newJSVal(v) {
-    const k = this.#allocKey();
+    const k = ++this.#lastk;
     this.#kv.set(k, v);
     return k;
   }