Skip to main content

Migrating OCaml Services from Lwt to Eio: A 2026 Guide

 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.

  1. Blocking CPU: If f() calculates a hash for 50ms, the entire event loop halts. No other requests are served.
  2. 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.

  1. Direct Style: No wrappers. Code looks synchronous but executes asynchronously.
  2. 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:

  1. Initialize the Eio runtime.
  2. Run the Lwt engine inside Eio.
  3. Offload the heavy_signing to a separate CPU core (Domain) to keep the I/O loop responsive.
  4. Bridge the Lwt DB call back to Eio style.

Dependencies: eio_mainlwt_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_serverLwt_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.t promise.
  • 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 T directly, not T 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.detach to offload work to a fixed thread pool. This was heavy and detached from the OCaml GC nuances.
  • Eio.Domain_manager spawns 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:

  1. Wrap your entry point with Lwt_eio.with_event_loop.
  2. Await legacy Lwt calls using Lwt_eio.Promise.await_lwt to flatten the monad.
  3. Dispatch CPU-heavy logic to env#domain_mgr to unlock multicore performance.

This approach lets you modernize critical hot paths today without rewriting your entire dependency tree.