Skip to content

Journey 02 CPS Async

Roger Johansson edited this page Jan 15, 2026 · 3 revisions

Chapter 2: CPS for Async

November 2025 - "Async/await is just syntax sugar" and other lies I told myself

The Problem

OK so the basic engine worked. Functions, closures, objects, prototypes - all good.

Then someone (me) decided we needed async/await support.

How hard can it be? It's basically just syntax sugar over Promises, right?

Wrong. So very wrong.

The problem: in a tree-walking interpreter, how do you pause execution mid-function and resume later? The call stack is the C# call stack. You can't just... stop.

Enter CPS

CPS stands for Continuation-Passing Style.

The idea: instead of returning values, you pass a "continuation" - basically just a function that receives the result and does whatever comes next.

// Normal style
function add(a, b) {
    return a + b;
}
const result = add(1, 2);
console.log(result);

// CPS style
function addCPS(a, b, continuation) {
    continuation(a + b);
}
addCPS(1, 2, (result) => {
    console.log(result);
});

The insight: if we transform async functions into CPS, every await becomes a point where we can pass control back to the event loop.

The Transformation

An async function like:

async function fetchBoth() {
    const a = await fetch('/a');
    const b = await fetch('/b');
    return a + b;
}

Gets transformed (conceptually) into:

function fetchBoth() {
    return new Promise((resolve, reject) => {
        fetch('/a').then((a) => {
            fetch('/b').then((b) => {
                resolve(a + b);
            }).catch(reject);
        }).catch(reject);
    });
}

Each await becomes a .then(). The transformation threads continuations through the entire function body.

Nice, right?

Implementation

But: the code got... complicated.

The CPS transformer walked the S-expression AST and rewrote async functions:

public Cons TransformAsyncFunction(Cons asyncFunc)
{
    // Find all await expressions
    // Split the function at each await point
    // Wrap each segment in a continuation
    // Chain via Promise.then()

    // This is where I started questioning my life choices
}

Every control flow construct needed special handling:

  • if statements with awaits in both branches
  • loops with awaits in the body
  • try/catch with awaits in the try block AND the catch block
  • nested async functions

The Event Loop

Promises need an event loop. I used .NET's Channel<T>:

public class EventQueue
{
    private readonly Channel<Action> _channel;

    public void EnqueueMicrotask(Action task)
    {
        _channel.Writer.TryWrite(task);
    }

    public async Task DrainAsync()
    {
        while (_channel.Reader.TryRead(out var task))
            task();
    }
}

Microtasks (Promise continuations) get processed before macrotasks (setTimeout). Getting this order right took longer than I'd like to admit. :-)

setTimeout/setInterval

Once we had the event queue, timers were straightforward:

setTimeout(() => console.log('later'), 1000);
setInterval(() => console.log('again'), 500);

These just schedule callbacks on the macrotask queue with a delay. The event loop processes them when appropriate.

Did It Work?

Yes! Async/await worked. You could write:

async function test() {
    console.log('start');
    await delay(100);
    console.log('middle');
    await delay(100);
    console.log('end');
}

And it would execute correctly, yielding between awaits.

But...

The CPS approach had problems:

  1. Code explosion - Transformed code was much larger
  2. Stack depth - Deep continuations could overflow
  3. Debugging - Good luck tracing through transformed code
  4. Performance - All that closure allocation adds up

So, it worked. But I wasn't happy with it.

This would get rewritten (Chapter 7 spoiler).

Lessons

  • Async is not "just syntax sugar" - it fundamentally changes execution flow
  • CPS is powerful but has costs
  • The ECMAScript spec for Promises is... thorough (and humbling)
  • Sometimes "it works" is good enough to ship, but not good enough to keep

Previous: Chapter 1: Genesis Next: Chapter 3: Generators - Where state machines enter the chat

//Roger

Clone this wiki locally