The OCaml ecosystem has fundamentally shifted. For over a decade, Lwt (and Async) provided the standard for concurrency via cooperative threading and the Promise monad. While effective for single-core I/O, this model became a bottleneck with the release of OCaml 5.
Legacy Lwt applications are largely single-threaded. They cannot utilize the true parallelism offered by OCaml 5’s Multicore runtime without spinning up heavy system processes or relying on complex work-stealing pools. Furthermore, the "colored function" problem—where Lwt.t infects every function signature—creates cognitive load and obfuscates stack traces.
We are migrating a compute-heavy IO service from Lwt to Eio (Effects-based IO). Eio leverages OCaml 5's algebraic effects to provide direct-style concurrency (no monads) and seamless domain (core) utilization.
The Root Cause: Monads vs. Effects
To migrate effectively, you must understand the mechanical difference in suspension.
The Lwt Model (The Bottleneck)
Lwt uses a monadic promise. When you write let* x = f (), you are constructing a callback chain. The runtime is an event loop running on a single OS thread.
- Blocking CPU: If
f()calculates a hash for 50ms, the entire event loop halts. No other requests are served. - Concurrency, not Parallelism: You can have 10,000 concurrent connections, but you are only ever using 100% of one CPU core.
The Eio Model (The Solution)
Eio uses Algebraic Effects. When an I/O operation occurs, the runtime suspends the current Fiber (green thread) and switches to another.
- Direct Style: No wrappers. Code looks synchronous but executes asynchronously.
- Domains: Eio allows you to spawn fibers onto different
Domains(OS threads). This allows true parallel execution of CPU-bound tasks alongside I/O.
The Migration Strategy: The Lwt_eio Bridge
Rewriting an entire backend from scratch is rarely feasible. The robust path in 2026 is a gradual migration using Lwt_eio, which allows the Lwt event loop to run inside the Eio main loop. This lets you run legacy libraries (like cohttp or pgocaml) alongside new Eio logic.
1. The Legacy Service (Lwt)
Consider a service that receives a payload, cryptographically signs it (CPU bound), and stores it in Postgres (IO bound).
(* legacy_handler.ml *)
open Lwt.Syntax
(* Simulated legacy DB driver *)
module Legacy_DB = struct
let insert data =
let* () = Lwt_unix.sleep 0.05 in (* Simulate network latency *)
Lwt.return (Printf.sprintf "Inserted: %s" data)
end
(* CPU intensive task *)
let heavy_signing data =
(* In Lwt, this BLOCKS the whole reactor unless we use Lwt_preemptive *)
let rec fib n = if n < 2 then n else fib (n - 1) + fib (n - 2) in
let signature = fib 40 in
Printf.sprintf "%s-[sig:%d]" data signature
let handle_request_lwt data =
let signed = heavy_signing data in (* PROBLEM: Blocks loop *)
let* result = Legacy_DB.insert signed in
Lwt.return result
let main () =
Lwt_main.run (handle_request_lwt "payload")
2. The Hybrid Eio Implementation
We will refactor this to:
- Initialize the Eio runtime.
- Run the Lwt engine inside Eio.
- Offload the
heavy_signingto a separate CPU core (Domain) to keep the I/O loop responsive. - Bridge the Lwt DB call back to Eio style.
Dependencies: eio_main, lwt_eio.
(* modern_handler.ml *)
open Eio.Std
(* 1. Define the Mixed Environment *)
let run_hybrid_server fn =
Eio_main.run @@ fun env ->
Lwt_eio.with_event_loop ~clock:env#clock @@ fun () ->
fn env
(* 2. The CPU Task (Unchanged logic, different execution context) *)
let heavy_signing data =
let rec fib n = if n < 2 then n else fib (n - 1) + fib (n - 2) in
let signature = fib 40 in
Printf.sprintf "%s-[sig:%d]" data signature
(* 3. Legacy DB Wrapper *)
(* We wrap the Lwt promise to block the Eio fiber, not the OS thread *)
let db_insert_eio data =
Lwt_eio.Promise.await_lwt (
let open Lwt.Syntax in
let* () = Lwt_unix.sleep 0.05 in
Lwt.return (Printf.sprintf "Inserted: %s" data)
)
(* 4. The Request Handler *)
let handle_request env data =
(* Structured Concurrency: Use a Switch to manage resources *)
Switch.run @@ fun sw ->
traceln "Starting request on Domain: %d" (Domain.self () :> int);
(* A. PARALLELISM: Offload CPU work to a separate Domain *)
(* This frees up the main Eio loop to handle other incoming HTTP requests *)
let signed_data =
Eio.Domain_manager.run env#domain_mgr (fun () ->
traceln "Signing on Domain: %d" (Domain.self () :> int);
heavy_signing data
)
in
(* B. IO: Back to the main loop (or generic pool) for IO *)
(* This looks synchronous, but it yields the fiber *)
let result = db_insert_eio signed_data in
traceln "Done: %s" result
(* Entry Point *)
let () =
run_hybrid_server (fun env ->
(* Simulate concurrent requests *)
Fiber.both
(fun () -> handle_request env "payload_1")
(fun () -> handle_request env "payload_2")
)
Technical Breakdown
The Lwt_eio Event Loop
In run_hybrid_server, Lwt_eio.with_event_loop starts the Lwt reactor. Crucially, when Lwt runs out of immediate work and waits for I/O (like Lwt_unix.sleep), it doesn't block the OS thread. Instead, it suspends the Eio fiber, allowing Eio to schedule other work.
Lwt_eio.Promise.await_lwt
This is the bridge function.
- Input: An
Lwt.tpromise. - Behavior: It registers a callback on the Lwt promise. If the promise is pending, it performs an algebraic effect to suspend the current Eio fiber. When Lwt resolves, it resumes the fiber.
- Result: You get the value
Tdirectly, notT Lwt.t. This allows you to write linear code.
Eio.Domain_manager.run
This is the critical upgrade from OCaml 4.x.
- Legacy Lwt apps often used
Lwt_preemptive.detachto offload work to a fixed thread pool. This was heavy and detached from the OCaml GC nuances. Eio.Domain_managerspawns the closure on a true parallel Domain (mapped to a hardware thread).- In the example above, while "payload_1" is calculating Fibonacci numbers on Core 2, the main loop on Core 1 immediately picks up "payload_2".
Structured Concurrency: The Switch
Notice the usage of Switch.run. In Lwt, "dangling promises" (futures that are created but never awaited) are a common source of memory leaks and silent failures.
Eio enforces Structured Concurrency.
Switch.run @@ fun sw ->
Fiber.fork ~sw (fun () -> ...); (* Child fiber *)
...
The Switch.run block cannot exit until all fibers attached to sw have completed. If an exception occurs in the main flow, the switch is turned off, and all child fibers are automatically cancelled. This creates robust, leak-free service architectures by default.
Summary
Migrating to Eio allows you to dismantle the "async/await" virus in your OCaml codebase. By using the Lwt_eio bridge, you can incrementally adopt OCaml 5 features:
- Wrap your entry point with
Lwt_eio.with_event_loop. - Await legacy Lwt calls using
Lwt_eio.Promise.await_lwtto flatten the monad. - Dispatch CPU-heavy logic to
env#domain_mgrto unlock multicore performance.
This approach lets you modernize critical hot paths today without rewriting your entire dependency tree.