diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Revision history for jsaddle-wasm
 
+## 0.1.0.0 -- 2025-03-06
+
+ * Add support for synchronous callbacks (when using `run`, but not `runWorker`) using the new synchronous Wasm JSFFI exports feature.
+   Correspondingly, jsaddle-wasm requires a GHC with support for that feature; please use older versions of this library if you can not yet upgrade.
+
 ## 0.0.1.0 -- 2024-10-26
 
  * Added `runWorker` and `jsaddleScript` for running JSaddle and WASM logic in different execution environments. See the README for details on how to use this.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
 [![Hackage](https://img.shields.io/hackage/v/jsaddle-wasm)](https://hackage.haskell.org/package/jsaddle-wasm)
 [![Haddocks](https://img.shields.io/badge/documentation-Haddocks-purple)](https://hackage.haskell.org/package/jsaddle-wasm/docs/Language-Javascript-JSaddle-Wasm.html)
 
-Run [JSaddle][] `JSM` actions with the [GHC WASM backend][].
+Run [JSaddle][] `JSM` actions with the [GHC Wasm backend][].
 
 This can for example be used to compile and run [Miso][] or [Reflex][] apps in the browser.
 
@@ -12,14 +12,18 @@
 
 ## Examples
 
- - Several Miso examples: https://github.com/tweag/ghc-wasm-miso-examples
+ - Miso examples: https://github.com/tweag/ghc-wasm-miso-examples
 
- - Reflex TodoMVC example: https://github.com/tweag/ghc-wasm-miso-examples/pull/18
+ - Reflex examples: https://github.com/tweag/ghc-wasm-reflex-examples
 
+ - Ormolu Live: https://github.com/tweag/ormolu/tree/master/ormolu-live
+   (uses the web worker approach described below)
+
 ## How to use
 
-Install a WASM-enabled GHC with support for the WASM JSFFI from [ghc-wasm-meta][] (GHC 9.10 or newer).
+Install a Wasm-enabled GHC with support for the Wasm JSFFI (including [synchronous JSFFI exports][sync-jsffi-exports][^missing-sync-jsffi]) from [ghc-wasm-meta][] (GHC 9.10 or newer).
 
+
 Assuming you built your application as an `app :: JSM ()`:
 
 ```haskell
@@ -31,14 +35,14 @@
 main = JSaddle.Wasm.run app
 ```
 
-Build the WASM binary with the following GHC options:
+Build the Wasm binary with the following GHC options:
 ```cabal
 ghc-options: -no-hs-main -optl-mexec-model=reactor "-optl-Wl,--export=hs_start"
 ```
 
 Now, run the post-linker script as described in the [GHC User's Guide][ghc-users-guide-js-api]; we will call the resulting JavaScript file `ghc_wasm_jsffi.js`.
 
-Then, following the [GHC User's Guide][ghc-users-guide-js-api], you can run the WASM binary in the browser via e.g. [browser_wasi_shim][]:
+Then, following the [GHC User's Guide][ghc-users-guide-js-api], you can run the Wasm binary in the browser via e.g. [browser_wasi_shim][]:
 ```javascript
 import { WASI, OpenFile, File, ConsoleStdout } from "@bjorn3/browser_wasi_shim";
 import ghc_wasm_jsffi from "./ghc_wasm_jsffi.js";
@@ -64,11 +68,11 @@
 
 ### Separating execution environments
 
-It is also possible to run the WASM worker in a different execution environment (e.g. a web worker) than the JSaddle JavaScript code that dispatches the JSaddle command messages.
+It is also possible to run the Wasm worker in a different execution environment (e.g. a web worker) than the JSaddle JavaScript code that dispatches the JSaddle command messages.
 
-An advantage of this approach is that computationally expensive operations in WASM do not block the UI thread. A disadvantage is that there is some overhead for copying the data back and forth, and everything relying on synchronous callbacks (e.g. `stopPropagation`/`preventDefault`) definitely no longer works.
+An advantage of this approach is that computationally expensive operations in Wasm do not block the UI thread. A disadvantage is that there is some overhead for copying the data back and forth, and everything relying on synchronous callbacks (e.g. `stopPropagation`/`preventDefault`) definitely no longer works.
 
- - Instead of the `run` function above, you need to use `runWorker`:
+ - Instead of the `run` function above, you need to use `runWorker` (again assuming `app :: JSM ()`):
 
    ```haskell
    import Language.Javascript.JSaddle.Wasm qualified as JSaddle.Wasm
@@ -76,12 +80,12 @@
    foreign export javascript "hs_runWorker" runWorker :: JSVal -> IO ()
 
    runWorker :: JSVal -> IO ()
-   runWorker = JSaddle.Wasm.runWorker Ormolu.Live.app
+   runWorker = JSaddle.Wasm.runWorker app
    ```
 
    The argument to `runWorker` here can be any message port in the sense of the [Channel Messaging API][]. In particular, it must provide a `postMessage` function and a `message` event.
 
-   For example, in a web worker, you can initialize the WASM module as above, and then run
+   For example, in a web worker, you can initialize the Wasm module as above, and then run
    ```javascript
    await instance.exports.hs_runWorker(globalThis);
    ```
@@ -101,15 +105,14 @@
 
 ## Potential future work
 
- - Take a closer look at synchronous callbacks. We have no special handling currently, but basic things like `stopPropagation`/`preventDefault` already seem to work fine with `run` (but not with `runWorker`, as expected).
  - Testing (e.g. via Selenium).
  - Add logging/stats.
  - Performance/benchmarking (not clear that this is actually a bottleneck for most applications).
     - Optimize existing command-based implementation.
        - Reuse buffers
        - Use a serialization format more efficient than JSON.
-    - Patch `jsaddle` to not go through commands, by using the WASM JS FFI.
-    - Implement `ghcjs-dom` API directly via the WASM JS FFI.
+    - Patch `jsaddle` to not go through commands, by using the Wasm JS FFI.
+    - Implement `ghcjs-dom` API directly via the Wasm JS FFI.
 
       This would involve creating a `ghcjs-dom-wasm` package by adapting the FFI import syntax from `ghcjs-dom-jsffi`/`ghcjs-dom-javascript` appropriately.
 
@@ -117,14 +120,17 @@
 
 ## Related projects
 
- - [WebGHC/jsaddle-wasm](https://github.com/WebGHC/jsaddle-wasm) for the analogue for [WebGHC][] instead of the [GHC WASM backend][].
+ - [WebGHC/jsaddle-wasm](https://github.com/WebGHC/jsaddle-wasm) for the analogue for [WebGHC][] instead of the [GHC Wasm backend][].
 
+[^missing-sync-jsffi]: Otherwise, you will see errors involving `unknown type name 'HsFUN'`.
+
 [JSaddle]: https://github.com/ghcjs/jsaddle
-[GHC WASM backend]: https://www.tweag.io/blog/2022-11-22-wasm-backend-merged-in-ghc
+[GHC Wasm backend]: https://www.tweag.io/blog/2022-11-22-wasm-backend-merged-in-ghc
 [Miso]: https://github.com/dmjio/miso
 [Reflex]: https://github.com/reflex-frp/reflex
-[ghc-wasm-meta]: https://gitlab.haskell.org/ghc/ghc-wasm-meta
+[ghc-wasm-meta]: https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta
 [browser_wasi_shim]: https://github.com/bjorn3/browser_wasi_shim
 [ghc-users-guide-js-api]: https://ghc.gitlab.haskell.org/ghc/doc/users_guide/wasm.html#the-javascript-api
 [WebGHC]: https://webghc.github.io
 [Channel Messaging API]: https://developer.mozilla.org/en-US/docs/Web/API/Channel_Messaging_API
+[sync-jsffi-exports]: https://gitlab.haskell.org/ghc/ghc/-/merge_requests/13994
diff --git a/jsaddle-wasm.cabal b/jsaddle-wasm.cabal
--- a/jsaddle-wasm.cabal
+++ b/jsaddle-wasm.cabal
@@ -1,8 +1,8 @@
 cabal-version: 3.0
 name: jsaddle-wasm
-version: 0.0.1.0
-synopsis: Run JSaddle JSM with the GHC WASM backend
-description: Run JSaddle @JSM@ with the GHC WASM backend.
+version: 0.1.0.0
+synopsis: Run JSaddle JSM with the GHC Wasm backend
+description: Run JSaddle @JSM@ with the GHC Wasm backend.
 category: Web, Javascript
 homepage: https://github.com/amesgen/jsaddle-wasm
 bug-reports: https://github.com/amesgen/jsaddle-wasm/issues
@@ -58,7 +58,7 @@
     build-depends:
       aeson >=2 && <2.3,
       bytestring >=0.11 && <0.13,
-      ghc-experimental ^>=0.1,
+      ghc-experimental ^>=0.1 || >=9.1000 && <9.1300,
       stm ^>=2.5,
 
     hs-source-dirs: src-wasm
diff --git a/src-native/Language/Javascript/JSaddle/Wasm/Internal.hs b/src-native/Language/Javascript/JSaddle/Wasm/Internal.hs
--- a/src-native/Language/Javascript/JSaddle/Wasm/Internal.hs
+++ b/src-native/Language/Javascript/JSaddle/Wasm/Internal.hs
@@ -9,10 +9,10 @@
 
 run :: JSM () -> IO ()
 run _ =
-  fail "Language.Javascript.JSaddle.Wasm.run: only works on WASM backend"
+  fail "Language.Javascript.JSaddle.Wasm.run: only works on Wasm backend"
 
 runWorker :: JSM () -> JSVal -> IO ()
 runWorker _ _ =
-  fail "Language.Javascript.JSaddle.Wasm.runWorker: only works on WASM backend"
+  fail "Language.Javascript.JSaddle.Wasm.runWorker: only works on Wasm backend"
 
 data JSVal
diff --git a/src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs b/src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs
--- a/src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs
+++ b/src-wasm/Language/Javascript/JSaddle/Wasm/Internal.hs
@@ -21,9 +21,9 @@
 import Language.Javascript.JSaddle.Types (Batch, JSM)
 
 -- Note: It is also possible to implement this succinctly on top of 'runWorker'
--- and 'jsaddleScript', but then e.g. @stopPropagation@/@preventDefault@
--- definitely don't work, whereas they work (at least in simple cases) with the
--- implementation below.
+-- and 'jsaddleScript' (using MessageChannel), but then e.g.
+-- @stopPropagation@/@preventDefault@ don't work, whereas the implementation
+-- below has special support via @processResultSync@.
 run :: JSM () -> IO ()
 run entryPoint = do
   -- TODO rather use a bounded (even size 1) queue?
@@ -35,8 +35,8 @@
   readBatchCallback <-
     mkPushCallback $ atomically $ readTQueue outgoingMsgQueue
 
-  let jsaddleRunner :: JSVal -> IO ()
-      jsaddleRunner processResultCallback = do
+  let jsaddleRunner :: JSVal -> JSVal -> IO ()
+      jsaddleRunner processResultCallback processResultSyncCallback = do
         s <-
           byteStringToJSString . BLC8.toStrict . BLC8.unlines $
             [ JSaddle.Files.ghcjsHelpers,
@@ -46,14 +46,11 @@
               "    const batch = JSON.parse(await readBatch());",
               JSaddle.Files.runBatch
                 (\r -> "processResult(JSON.stringify(" <> r <> "));")
-                -- TODO Think more about how synchronous dispatching works here
-                -- (see processSyncResult). For some reason, it already /seems/
-                -- to work fine, at least in simple cases.
-                Nothing,
+                (Just \r -> "JSON.parse(processResultSync(JSON.stringify(" <> r <> ")))"),
               "  }",
               "})();"
             ]
-        evaluate =<< js_eval s processResultCallback readBatchCallback
+        evaluate =<< js_eval s processResultCallback processResultSyncCallback readBatchCallback
 
   runHelper entryPoint sendOutgoingMessage jsaddleRunner
 
@@ -62,28 +59,39 @@
   runHelper
     entryPoint
     (evaluate <=< js_postMessage worker)
-    (evaluate <=< js_onMessage worker)
+    (\processResult _processResultSync -> evaluate =<< js_onMessage worker processResult)
 
 runHelper ::
   JSM () ->
   -- | How to send an outgoing message.
   (JSString -> IO ()) ->
-  -- | Start receiving incoming messages. For every message, invoke the given
-  -- 'JSVal' callback.
-  (JSVal -> IO ()) ->
+  -- | Start receiving incoming messages. For every message, invoke one of the
+  -- two given 'JSVal' callbacks (for sync/async processing, respectively).
+  (JSVal -> JSVal -> IO ()) ->
   IO ()
 runHelper entryPoint sendOutgoingMessage onIncomingMessage = do
-  (processResult, _processSyncResult, start) <-
+  (processResult, processSyncResult, start) <-
     runJavaScript sendBatch entryPoint
 
-  processResultCallback <- mkPullCallback \s -> do
-    bs <- jsStringToByteString s
-    case A.eitherDecodeStrict bs of
-      Left e -> fail $ "jsaddle: received invalid JSON: " <> show e
-      Right r -> processResult r
+  let receiveBatch :: JSString -> IO ()
+      receiveBatch s = do
+        bs <- jsStringToByteString s
+        case A.eitherDecodeStrict bs of
+          Left e -> fail $ "jsaddle: received invalid JSON: " <> show e
+          Right r -> processResult r
 
-  onIncomingMessage processResultCallback
+      processBatchSync :: JSString -> IO JSString
+      processBatchSync s = do
+        bs <- jsStringToByteString s
+        case A.eitherDecodeStrict bs of
+          Left e -> fail $ "jsaddle: received invalid JSON: " <> show e
+          Right r -> byteStringToJSString . BLC8.toStrict . A.encode =<< processSyncResult r
 
+  processResultCallback <- mkPullCallback receiveBatch
+  processResultSyncCallback <- mkSyncCallback processBatchSync
+
+  onIncomingMessage processResultCallback processResultSyncCallback
+
   start
   where
     sendBatch :: Batch -> IO ()
@@ -95,10 +103,12 @@
 
 foreign import javascript "wrapper" mkPullCallback :: (JSString -> IO ()) -> IO JSVal
 
+foreign import javascript "wrapper sync" mkSyncCallback :: (JSString -> IO JSString) -> IO JSVal
+
 foreign import javascript "wrapper" mkPushCallback :: IO JSString -> IO JSVal
 
-foreign import javascript safe "new Function('processResult','readBatch',`(()=>{${$1}})()`)($2, $3)"
-  js_eval :: JSString -> JSVal -> JSVal -> IO ()
+foreign import javascript safe "new Function('processResult','processResultSync','readBatch',`(()=>{${$1}})()`)($2, $3, $4)"
+  js_eval :: JSString -> JSVal -> JSVal -> JSVal -> IO ()
 
 -- Worker
 
diff --git a/src/Language/Javascript/JSaddle/Wasm.hs b/src/Language/Javascript/JSaddle/Wasm.hs
--- a/src/Language/Javascript/JSaddle/Wasm.hs
+++ b/src/Language/Javascript/JSaddle/Wasm.hs
@@ -1,7 +1,7 @@
 -- | See the [README](https://github.com/amesgen/jsaddle-wasm) for more details.
 --
--- While this package also compiles on non-WASM GHCs for convenience, running
--- this function will immediately fail.
+-- While this package also compiles on non-Wasm GHCs for convenience, running
+-- any function from this module will immediately fail in that case.
 module Language.Javascript.JSaddle.Wasm
   ( run,
     runWorker,
@@ -17,7 +17,7 @@
 import Language.Javascript.JSaddle.Wasm.Internal qualified as Internal
 import Language.Javascript.JSaddle.Wasm.JS (jsaddleScript)
 
--- | Run a 'JSM' action via the WASM JavaScript FFI.
+-- | Run a 'JSM' action via the Wasm JavaScript FFI.
 run :: JSM () -> IO ()
 run = Internal.run
 
