Async Without the Color: How Koru Solves the Function Coloring Problem

· 14 min read

“Once you go async, you can never go back.” — Every developer who added async to one function

The Function Coloring Problem

In 2015, Bob Nystrom wrote an essay that perfectly captured a fundamental problem with async/await: “What Color Is Your Function?”

The insight: async/await creates two incompatible worlds.

Imagine functions come in two colors: red (async) and blue (sync).

The rules:

  • Red functions can call red or blue functions
  • Blue functions can ONLY call blue functions
  • If a blue function needs to call a red function, it must become red

This sounds abstract. Let’s make it concrete with real code.

The Async Infection in JavaScript

You start with this:

function getUserEmail(userId) {
  const user = getUser(userId);
  return user.email;
}

Simple. Clean. Synchronous.

Then someone says: “We need to fetch users from a database now.”

So getUser becomes async:

async function getUser(userId) {
  return await db.query('SELECT * FROM users WHERE id = ?', userId);
}

Now you must change your code:

async function getUserEmail(userId) {
  const user = await getUser(userId);  // Must add await
  return user.email;
}

And everything that calls getUserEmail:

async function sendWelcomeEmail(userId) {
  const email = await getUserEmail(userId);  // Must add await
  return await emailService.send(email);
}

And everything that calls sendWelcomeEmail:

async function onUserRegistration(userId) {
  await sendWelcomeEmail(userId);  // Must add await
  await analytics.track(userId);
}

The async spreads like a virus. One async function at the bottom of your call stack infects everything above it.

Your function turned red. And once a function is red, it can never be blue again.

The Async Infection in C#

C# has the same problem, but with different syntax:

// Start here
public string GetUserEmail(Guid userId)
{
    var user = GetUser(userId);
    return user.Email;
}

// Someone makes this async
public async Task<User> GetUser(Guid userId)
{
    return await _db.GetUserById(userId);
}

// Now EVERYTHING must change
public async Task<string> GetUserEmail(Guid userId)
{
    var user = await GetUser(userId);  // Must await
    return user.Email;
}

public async Task SendWelcomeEmail(Guid userId)
{
    var email = await GetUserEmail(userId);  // Must await
    await _emailService.Send(email);
}

public async Task OnUserRegistration(Guid userId)
{
    await SendWelcomeEmail(userId);  // Must await
    await _analytics.Track(userId);
}

Notice how async and Task<> infect the entire call chain. You can’t call an async function from a sync function without blocking (which defeats the purpose).

Functions have been colored. Red functions (async) and blue functions (sync) live in separate worlds.

The Rust Version: .await Everywhere

Rust futures have the same problem:

// Before
fn get_user_email(user_id: UserId) -> String {
    let user = get_user(user_id);
    user.email
}

// After someone makes get_user async
async fn get_user(user_id: UserId) -> User {
    db.query("SELECT * FROM users WHERE id = ?", user_id).await
}

// Now everything must change
async fn get_user_email(user_id: UserId) -> String {
    let user = get_user(user_id).await;  // Must await
    user.email
}

async fn send_welcome_email(user_id: UserId) {
    let email = get_user_email(user_id).await;  // Must await
    email_service.send(email).await;
}

Same virus. Same infection. Different syntax.

Why This Is a Problem

The function coloring problem creates several issues:

1. Refactoring Hell

Making one function async forces you to make every caller async. This can mean touching hundreds of files.

2. Two Separate Ecosystems

Libraries split into sync and async versions:

  • reqwest (async) vs ureq (sync) in Rust
  • axios (async) vs… well, everything is async in JavaScript now

3. Beginner Confusion

New developers see:

async function doThing() {
  await doOtherThing();
}

And ask: “Why do I need async AND await? What’s the difference?”

The answer involves explaining futures, event loops, and call stack coloring.

4. Performance Traps

Accidentally not awaiting:

async function processItems(items) {
  items.forEach(item => processItem(item));  // BUG! Not awaited
}

This compiles. It runs. It’s wrong. processItem returns promises that are never awaited.

How Koru Solves This: Progressive Disclosure

Koru takes a radically different approach: async is an implementation detail, not a signature change.

Let’s start with a simple event:

// Public interface - looks synchronous
~pub event processOrder { order: Order }
| processed { result: OrderResult }
| failed { reason: []const u8 }

The proc implementation can be async:

~proc processOrder {
    // Implementation uses async I/O, thread pools, whatever
    const validation = await validateOrder(order);
    const inventory = await checkInventory(order);
    const payment = await processPayment(order);

    // Return result
    return .{ .processed = .{ .result = result } };
}

The caller doesn’t know it’s async:

~order.processOrder(order: myOrder)
| processed r |> logSuccess(result: r.result)
| failed f |> logError(msg: f.reason)

No async. No await. The function has no color.

The async complexity is hidden inside the proc. The event signature remains clean.

But What If I Want Parallelism?

This is where progressive disclosure shines. For advanced users who want explicit async control, provide additional interfaces:

// Simple interface (async hidden)
~pub event processOrder { order: Order }
| processed { result: OrderResult }
| failed { reason: []const u8 }

// Advanced interface - explicit async
~pub event processOrder.async { order: Order }
| awaitable { handle: AsyncHandle(OrderResult) }

// Advanced interface - explicit await
~pub event processOrder.await { handle: AsyncHandle(OrderResult) }
| processed { result: OrderResult }
| failed { reason: []const u8 }

// Advanced interface - join multiple
~pub event processOrder.join { handles: []AsyncHandle(OrderResult) }
| all_completed { results: []OrderResult }
| some_failed { successful: []OrderResult, failed: []Error }

Now you can choose your abstraction level:

Beginner: Simple Interface

~order.processOrder(order: order1)
| processed r1 |> order.processOrder(order: order2)
    | processed r2 |> order.processOrder(order: order3)
        | processed r3 |> combineResults(r1: r1.result, r2: r2.result, r3: r3.result)

Sequential. Simple. No async keywords. It just works.

Advanced: Explicit Parallelism

// Start all three in parallel
~order.processOrder.async(order: order1)
| awaitable h1 |> order.processOrder.async(order: order2)
    | awaitable h2 |> order.processOrder.async(order: order3)
        | awaitable h3 |>
            // Wait for all to complete
            order.processOrder.join(handles: &[_]{h1.handle, h2.handle, h3.handle})
            | all_completed results |> combineResults(results: results.results)
            | some_failed f |> handlePartialFailure(
                successful: f.successful,
                failed: f.failed)

Parallel execution. Full control. Still clear and explicit.

Mixed: Best of Both Worlds

// Some sequential, some parallel - they compose!
~order.processOrder(order: order1)  // Simple version
| processed r1 |>
    // These two in parallel
    order.processOrder.async(order: order2)
    | awaitable h2 |> order.processOrder.async(order: order3)
        | awaitable h3 |>
            order.processOrder.join(handles: &[_]{h2.handle, h3.handle})
            | all_completed results |>
                combineResults(r1: r1.result, r2: results.results[0], r3: results.results[1])

The colorless (simple) and colored (async) versions compose seamlessly.

Why This Works: Events vs Functions

The key insight: Events are contracts (WHAT), procs are implementations (HOW).

Async is a HOW (implementation strategy), not a WHAT (business contract).

The event says:

~event processOrder { order: Order }
| processed { result: OrderResult }
| failed { reason: []const u8 }

This means: “Give me an order. I’ll either give you a processed result or tell you why it failed.”

It doesn’t say HOW:

  • Sync or async?
  • One thread or thread pool?
  • Sequential or parallel?

Those are proc implementation details. The caller shouldn’t care.

By providing .async/.await/.join variants, you give power users explicit control when they need it, without forcing everyone to think about concurrency.

The Beautiful Part: No Refactoring Hell

Let’s say you wrote code using the simple interface:

~order.processOrder(order: myOrder)
| processed r |> doSomethingElse(result: r.result)

Later, you realize you need to process multiple orders in parallel. You change to:

~order.processOrder.async(order: order1)
| awaitable h1 |> order.processOrder.async(order: order2)
    | awaitable h2 |>
        order.processOrder.join(handles: &[_]{h1.handle, h2.handle})
        | all_completed results |> doSomethingElse(results: results.results)

But the processOrder event itself didn’t change. And code elsewhere using the simple processOrder interface still works. No viral infection. No forced refactoring of callers.

Functions stayed colorless.

Comparison: Same Logic, Different Approaches

Let’s process three orders in parallel and combine results.

JavaScript (Async Everywhere)

async function processThreeOrders(order1, order2, order3) {
  // Must use Promise.all, must be async, must await
  const [result1, result2, result3] = await Promise.all([
    processOrder(order1),
    processOrder(order2),
    processOrder(order3)
  ]);

  return combineResults(result1, result2, result3);
}

// This function is now RED
// Every caller must be async

C# (Async Everywhere)

public async Task<CombinedResult> ProcessThreeOrders(
    Order order1, Order order2, Order order3)
{
    // Must use Task.WhenAll, must be async, must await
    var tasks = new[] {
        ProcessOrder(order1),
        ProcessOrder(order2),
        ProcessOrder(order3)
    };

    var results = await Task.WhenAll(tasks);
    return CombineResults(results[0], results[1], results[2]);
}

// This function is now RED
// Every caller must be async

Rust (Async Everywhere)

async fn process_three_orders(
    order1: Order,
    order2: Order,
    order3: Order,
) -> CombinedResult {
    // Must use join!, must be async, must await
    let (result1, result2, result3) = tokio::join!(
        process_order(order1),
        process_order(order2),
        process_order(order3)
    );

    combine_results(result1, result2, result3)
}

// This function is now RED
// Every caller must be async

Koru (Colorless)

// Simple version - no async keywords
~event processThreeOrders { order1: Order, order2: Order, order3: Order }
| combined { result: CombinedResult }

~proc processThreeOrders {
    // Proc can use .async/.join internally
    const h1 = order.processOrder.async(order1);
    const h2 = order.processOrder.async(order2);
    const h3 = order.processOrder.async(order3);

    const results = order.processOrder.join(&[_]{h1, h2, h3});
    return .{ .combined = .{ .result = combineResults(results) } };
}

// Callers use it like any other event - no color!
~processThreeOrders(order1: o1, order2: o2, order3: o3)
| combined c |> useResult(result: c.result)

The event signature is colorless. The async is hidden in the proc.

The Progressive Disclosure Philosophy

Koru’s approach follows a principle: simple things should be simple, complex things should be possible.

For 80% of use cases:

~order.processOrder(order: myOrder)
| processed r |> next(result: r.result)

No async keywords. No concurrency primitives. It just works.

For the 20% that need explicit control:

~order.processOrder.async(order: myOrder)
| awaitable h |> order.processOrder.await(handle: h.handle)
    | processed r |> next(result: r.result)

Full control available. But you opted in. You didn’t pay the complexity cost unless you needed it.

How This Ties to Everything Else

This progressive disclosure pattern connects to other Koru design principles:

Single Responsibility (Previous Post)

Each event variant has ONE job:

  • processOrder - process and return result (simple)
  • processOrder.async - start processing, return handle (explicit async)
  • processOrder.await - wait for handle, return result (explicit await)
  • processOrder.join - wait for multiple handles (explicit join)

Granular, focused, composable.

Bounded Contexts

Each event is a complete interface contract. The .async variant is a different contract (returns handle instead of result), but both are self-contained.

Free Monad (Events Are Monads)

The simple interface and async interface are different interpretations of the same computation:

  • Simple: processOrder evaluates immediately to result
  • Async: processOrder.async returns future, .await evaluates it

Same computational structure, different interpretations. That’s the Free Monad!

The Controversial Claim

Async/await in most languages is a language-level mistake.

It solves the wrong problem. The problem isn’t “how do we make async code look synchronous?” The problem is “why are we forcing users to care about async at all?”

Most callers don’t care if you use:

  • Synchronous I/O
  • Async I/O
  • Thread pools
  • Green threads
  • Actors

They care about: “Give me the result.”

Async is an implementation detail that leaked into the type system and infected everything.

Koru fixes this by:

  1. Hiding async by default (colorless functions)
  2. Exposing it progressively (.async/.await/.join for power users)
  3. Keeping it composable (colorless and colored code mix freely)

The Path Forward

If you’re tired of async/await infecting your codebase, if you’re sick of refactoring hundreds of functions because one became async, Koru offers a different model:

  1. Write events with clean signatures - describe WHAT, not HOW
  2. Implement procs with async as needed - use the best strategy
  3. Provide .async variants for power users - progressive disclosure
  4. Let simple code stay simple - no viral infection

Functions don’t need colors. They need clear contracts.

What’s Next?

Want to understand the foundations that make this work?


Think function coloring isn’t a problem? Want to defend async/await? Let’s discuss! Join us on GitHub Discussions or Discord.

Read Bob Nystrom’s original essay: “What Color Is Your Function?” - it’s brilliant.