← back to writing · February 2026

OCaml 5, Effect Handlers, and the Slow Death of Colored Functions

A gentle introduction to effect handlers, why async changes spread through a call stack, and the alternative OCaml 5 offers.

This started with a frustrating refactor. I needed one function at the bottom of a Python service to query a database asynchronously. That single change forced every function calling it—and several tests—to adopt an asynchronous interface too.

If OCaml is unfamiliar: it is a statically typed functional programming language used in areas including compilers, program analysis, and finance. OCaml 5 added support for multicore parallelism along with a feature called effect handlers. You do not need to know OCaml syntax to follow the idea.

In plain language, an effect lets a function pause and ask the surrounding program to do something on its behalf: fetch data, yield to a scheduler, read some state, or choose between alternatives. A handler decides what that request means and can then resume the function with a result.

Somewhere in the middle of that I reread Bob Nystrom's 2015 essay, "What Color is Your Function?" If you haven't read it, the core idea:

In languages with async/await, every function secretly has a color — synchronous (blue) or asynchronous (red). Red functions can call blue functions, but blue functions cannot call red functions. And once a function turns red, everything that calls it must turn red too.

This sync/async distinction appears in languages including JavaScript, Python, Rust, C#, and Kotlin. Each language handles the details differently.

OCaml takes a different approach. Its handlers make a side effect something the surrounding code interprets rather than a property fixed in the function's type. This post works through that approach from the beginning.

How Async Propagates

Here's a toy version of what happened to me. You have a perfectly reasonable call stack:

JavaScript
function renderDashboard() {
  const data = processData(getMetrics());
  return formatChart(data);
}

function processData(raw) {
  return raw.filter(valid).map(normalize);
}

function getMetrics() {
  return db.query("SELECT * FROM metrics");
}

Now someone tells you db.query needs to be async. Here's what happens:

Interactive: Async Propagation
Click "Make db.query async" to see which callers also need to change.
renderDashboard()
↓ calls
processData()
↓ calls
getMetrics()
↓ calls
db.query()
sync (blue)
async (red)

Step by step: db.query now returns a Promise. getMetrics must await that promise, so getMetrics becomes async and now returns a promise of its own. processData must await getMetrics, which makes processData async. Finally, renderDashboard must await processData. Tests and mocks that call these functions may need to follow the same interface.

Practical consequences

Beyond changing several function declarations, the sync/async distinction affects how code can be combined:

What OCaml 5 Did Instead

The Key Idea

Instead of baking side effects into a function's type, let the function declare what it needs (an effect) and let the caller decide how to provide it (a handler). The function doesn't know or care whether the effect is satisfied synchronously, asynchronously, or not at all.

Here is a small example:

OCaml 5
(* 1. Declare an effect — this is a "need" *)
type _ Effect.t += Fetch : string -> string Effect.t

(* 2. Use it — looks completely normal *)
let get_metrics () =
  Effect.perform (Fetch "https://api.example.com/metrics")

let process_data raw =
  raw |> List.filter valid |> List.map normalize

let render_dashboard () =
  let data = process_data (get_metrics ()) in
  format_chart data

No async, no await, no Promise. get_metrics looks like a regular function because it is one. The interesting part is the handler — the thing that decides what Fetch actually does:

OCaml 5 — The Handler
(* 3. Handle the effect — the caller decides *)
let run_with_http f =
  Effect.Deep.try_with f ()
    { effc = fun (type a) (eff : a Effect.t) ->
      match eff with
      | Fetch url ->
        Some (fun (k : (a, _) Effect.Deep.continuation) ->
          let body = Http.get url in   (* do the actual I/O *)
          Effect.Deep.continue k body)   (* resume with result *)
      | _ -> None }

(* Use it *)
let () = run_with_http render_dashboard

The handler catches the Fetch effect, does the actual HTTP call, and resumes the original function with the result. The function that performed the effect never finds out any of this happened — from its point of view, it asked for a string and got a string.

Step Through It: How Perform and Resume Work

Here is the control flow step by step:

⚡ Interactive: Effect Handler Simulator
Step through how perform and continue bounce control between the function and the handler.
Code
let render_dashboard () =
let raw = perform (Fetch url) in
let data = process raw in
format data
(* handler *)
| Fetch url → continue k (http_get url)
Execution Log

The thing to notice: the function's control flow stays linear. It hits perform, suspends, the handler runs, and then it resumes exactly where it left off with the value the handler provided. No callbacks, no .then(), no color change.

The mental model that finally stuck for me: it's a resumable exception. perform throws an effect upward, a handler catches it — but unlike an exception, the handler can hand a value back and let the original code keep going as if nothing happened.

The Deeper Insight: Effects Are a Composition Primitive

The same mechanism can express exceptions, state, nondeterminism, logging, and concurrency:

Effects beyond async
(* State as an effect *)
type _ Effect.t += Get : int Effect.t
type _ Effect.t += Set : int -> unit Effect.t

(* Nondeterminism as an effect *)
type _ Effect.t += Choose : bool Effect.t

(* A function that uses both — no framework needed *)
let my_function () =
  let x = perform Get in
  if perform Choose then
    perform (Set (x + 1))
  else
    perform (Set (x * 2))

A handler for Choose could explore both branches and collect every possible final state — a backtracking search in a few lines. Or flip a coin. Or ask the user. The function does not need to change.