-
Notifications
You must be signed in to change notification settings - Fork 1
Journey 02 CPS Async
November 2025 - "Async/await is just syntax sugar" and other lies I told myself
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.
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.
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?
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:
-
ifstatements 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
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. :-)
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.
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.
The CPS approach had problems:
- Code explosion - Transformed code was much larger
- Stack depth - Deep continuations could overflow
- Debugging - Good luck tracing through transformed code
- Performance - All that closure allocation adds up
So, it worked. But I wasn't happy with it.
This would get rewritten (Chapter 7 spoiler).
- 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