In 2015, Bob Nystrom wrote an essay called "What Color is Your Function?" that quietly diagnosed a disease that had already infected every mainstream language. The essay is short, funny, and devastating. If you haven't read it, the core idea is this:
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.
That essay is now eleven years old. And here's what's wild: almost nothing has changed. JavaScript, Python, Rust, C#, Dart, Kotlin — they all still have colored functions. They all still force you to choose sync or async at function definition time, and that choice propagates virally through your entire codebase.
OCaml 5, released in late 2022, took a completely different path. It introduced algebraic effect handlers — a mechanism that eliminates function coloring entirely. Not by removing async. Not by hiding it behind threads. By making side effects a value you can intercept and reinterpret, rather than a property baked into the function's type.
This matters. And almost nobody outside the PL research community is talking about it.
The Infection Pattern
Let me show you the problem concretely. Say you have a perfectly reasonable call stack:
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:
One change at the bottom of the stack forced every function above it to change its signature. getMetrics now returns a Promise. So processData needs await, which means it returns a Promise. So renderDashboard needs await too.
This isn't a JavaScript problem. It's a structural problem with how async/await works in every language that adopted it. The function's "color" — whether it's sync or async — is part of its interface, not its implementation. And interfaces propagate.
Why this actually hurts
It's not just annoying refactoring. Colored functions break composition in deep ways:
- You can't use async inside
Array.map—[1,2,3].map(async x => await fetch(x))gives youPromise[], not the values. You needPromise.all. Every higher-order function becomes a trap. - You can't implement sync interfaces with async code. If a library expects a comparator
(a, b) => number, you can't make it async. Period. Even if the comparison needs network data. - Testing becomes harder. Your test wants to provide a mock synchronously, but the function signature demands a Promise. So your test becomes async too. The infection never stops.
- Two ecosystems diverge. Every utility function eventually gets duplicated:
mapandmapAsync,retryandretryAsync. Two parallel worlds that can't share code.
What OCaml 5 Did Instead
OCaml 5 introduced effect handlers — based on decades of PL research on algebraic effects (Plotkin & Pretnar, 2009). The core insight is deceptively simple:
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.
Think of it like this. In async/await, a function that does I/O is permanently branded: "I am async." In OCaml 5, a function that does I/O just says: "I need some data" — and someone else decides how to get it.
Here's what the syntax looks like:
(* 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
Notice anything? No async. No await. No Promise. get_metrics looks like a regular function. render_dashboard looks like a regular function. That's because they are regular functions.
The magic is in the handler — the thing that decides what Fetch actually does:
(* 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 doesn't know any of this happened — it just got back a string.
Step Through It: How Perform and Resume Work
This is where most explanations lose people. So let's trace it step by step.
perform and continue bounce control between the function and the handler.The critical thing to notice: the function's control flow is linear. It hits perform, gets suspended, the handler runs, and then the function resumes exactly where it left off with the value the handler provided. No callbacks. No .then(). No color change.
This is essentially a resumable exception. perform throws an effect upward. The handler catches it, does some work, and then — unlike exceptions — gives a value back and lets the original code continue.
The Superpower: Swappable Handlers
Here's where it gets genuinely powerful — and where my original take comes in.
Because the handler is separate from the function, you can swap handlers without touching a single line of your business logic. Same function, different interpretation:
(* Production: real HTTP *) run_with_http render_dashboard (* Testing: deterministic mock *) run_with_mock ["url1", "fake_data"] render_dashboard (* Debugging: log every fetch, then do it *) run_with_logging render_dashboard (* Batching: collect all fetches, do them in parallel *) run_with_batch render_dashboard
That last one is wild. A batching handler can intercept every Fetch effect, collect them all, fire them in parallel, and resume each function with its result. The function code doesn't change. It doesn't even know it's being batched. You get automatic request deduplication and parallelism for free.
This is what dependency injection should have been. Not a framework. Not annotations. Not a DI container. Just: "I need X" / "Here's how X works."
The Deeper Insight: Effects Are a Composition Primitive
Most discussions of effect handlers stop at "it's better async." I think that misses the real point.
Effects are a universal composition primitive. They're not just for async — they can express exceptions, state, nondeterminism, logging, and concurrency. All with the same mechanism.
(* 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 all possible final states. That's a backtracking search engine in three lines. Or it could flip a coin. Or ask the user. The function doesn't care.
Here's the comparison that crystallizes it:
| Property | Async/Await | Effect Handlers |
|---|---|---|
| Function coloring | ✗ Yes — sync vs async | ✓ No — all functions are the same |
| Works with higher-order functions | ✗ map, filter etc. break | ✓ Transparent — just works |
| Testability | ✗ Tests must be async too | ✓ Swap in a sync mock handler |
| Batching / parallelism | ✗ Manual Promise.all | ✓ Handler can batch automatically |
| Beyond I/O | ✗ Only for async | ✓ State, nondeterminism, logging, etc. |
| Composability | ✗ Two parallel ecosystems | ✓ Handlers compose and nest |
Why This Should Matter to You (Even If You'll Never Write OCaml)
I'm not writing this to convert you to OCaml. I'm writing it because effect handlers represent a different way of thinking about program structure, and that way of thinking is leaking into other languages whether they adopt the syntax or not.
React's hooks? They're effects in disguise. useState, useEffect, useContext — these are all "perform an effect, let the framework handle it." React is the handler. Your component is the function. Dan Abramov has written about this explicitly.
Python's context managers? Partial effects. with open(file) as f is really "perform a resource acquisition effect, clean up when done."
Dependency injection frameworks? Trying to solve the same problem effects solve natively — "I need X, someone else provides X" — but with runtime reflection, annotations, and configuration files instead of a language primitive.
The pattern is everywhere. We're building ad-hoc, half-broken versions of effect handlers in every language because the underlying abstraction is so obviously right. OCaml just gave it a proper name and a sound implementation.
The Honest Limitations
I should be fair. Effect handlers aren't all roses:
- Tooling and ecosystem. OCaml's ecosystem is small. You won't find a package for everything. Jane Street's
BaseandCorelibraries are excellent, but it's not npm. - Learning curve. Continuations are hard to reason about. The type signatures for handlers are genuinely gnarly.
(a, _) Effect.Deep.continuationis not something you glance at and understand. - Performance overhead. Capturing and resuming continuations has a cost. It's been heavily optimized in OCaml 5 (using fiber-based continuations), but it's not zero.
- Type-level tracking. OCaml 5's effects are untyped — the type system doesn't track which effects a function might perform. Languages like Koka and Unison do track effects in the types, which is arguably more principled.
That last point is important. OCaml took a pragmatic approach: get effect handlers into the language now, even without full type-level effect tracking. The bet is that practical benefits outweigh theoretical purity. It's a very OCaml thing to do.
Where This Is Going
Effect handlers are on a slow collision course with mainstream languages. Rust is debating how to make async less viral. WebAssembly is getting stack switching, which enables effect-handler-like patterns. Even Java's Project Loom (virtual threads) is an admission that colored functions were a mistake — it solves the problem by making everything "async" under the hood so you never have to write the word.
OCaml just got there first, with the most general solution. It's the difference between patching symptoms and fixing the root cause.
The next time you're writing async in front of a function for the fourteenth time in a single refactor, just because one leaf function at the bottom of the call stack now talks to a database — remember that there's a language where that doesn't happen. Where a function that says "I need data" never has to know how the data arrives. Where the caller, not the callee, decides the execution strategy.
That's what OCaml 5 got right. Not better async. No async at all.