Cogs and Levers A blog full of technical stuff

Exploring Continuations and CPS Transformation

Introduction

Continuations are one of those ideas that seem abstract at first — but once they click, you’ll start seeing them everywhere: in asynchronous code, in exception handling, in generators, even in the way you reason about program flow.

In this post, we’ll explore what continuations are, how Continuation-Passing Style (CPS) transforms your code, and how these concepts are quietly powering modern async constructs like async/await.

We’ll walk through synchronous code, asynchronous patterns with callbacks and promises, and finally reach a new understanding of what’s really going on under the hood. You won’t need to reimplement a language or build a compiler to follow along — we’ll do everything with regular JavaScript, Python, and Rust.

What is a Continuation?

A continuation is a representation of “what to do next” in a program. At any point in your code, the rest of the computation can be thought of as a function — the continuation.

Let’s start simple:

function addOne(x) {
  return x + 1;
}

console.log(addOne(2)); // prints 3

Now, instead of returning, what if we passed the result to another function — the continuation?

function addOneCPS(x, cont) {
  cont(x + 1);
}

addOneCPS(2, (result) => {
  console.log(result); // prints 3
});

This style is called Continuation-Passing Style (CPS). In CPS, functions never return — they call their continuation instead.

This isn’t immediately remarkable. Callbacks have been used widely in code for quite some time now. This is just building a picture of where we’ve come from.

From Sync Code to CPS

Let’s expand our example to chain two operations:

function double(x) {
  return x * 2;
}

function addOne(x) {
  return x + 1;
}

console.log(addOne(double(5))); // 11

Now in CPS:

function doubleCPS(x, cont) {
  cont(x * 2);
}

function addOneCPS(x, cont) {
  cont(x + 1);
}

doubleCPS(5, (doubled) => {
  addOneCPS(doubled, (result) => {
    console.log(result); // 11
  });
});

We’ve restructured our program so that each step explicitly passes control to the next.

This may seem verbose, but it turns out to be extremely powerful — especially when dealing with asynchronous code.

CPS is Everywhere in JavaScript

Let’s look at an example from the Node.js world:

const fs = require("fs");

fs.readFile("data.txt", "utf8", (err, data) => {
  if (err) return console.error("Failed to read file:", err);
  processData(data, (result) => {
    console.log("Result:", result);
  });
});

This is literally CPS — instead of returning data, fs.readFile passes it to a callback.

What’s the callback? The continuation.

Promises: CPS with a Better API

JavaScript promises are built around the same idea, just cleaner:

fetch("/api/data")
  .then((res) => res.json())
  .then((data) => {
    console.log("Fetched:", data);
  })
  .catch((err) => {
    console.error("Error:", err);
  });

Every .then(fn) is passing the result to a new continuation function. But promises let us flatten the nesting and chain more cleanly.

async/await: The Illusion of Sync

Now the same thing with async/await:

async function loadData() {
  try {
    const res = await fetch("/api/data");
    const data = await res.json();
    console.log("Fetched:", data);
  } catch (err) {
    console.error("Error:", err);
  }
}

But this isn’t “real” synchronous code — it just looks like it.

Behind the scenes, the JavaScript engine is:

  • Splitting the function into pieces at each await
  • Saving the continuation (the rest of the function) in a hidden state machine
  • Resuming that continuation when the awaited promise resolves

That’s CPS at work.

Manual CPS in Other Languages

You don’t need a JavaScript engine to try this out. Let’s look at Rust and Python examples to see how CPS can be expressed in ordinary code.

Rust Example

fn double_cps(x: i32, cont: impl FnOnce(i32)) {
    cont(x * 2);
}

fn add_one_cps(x: i32, cont: impl FnOnce(i32)) {
    cont(x + 1);
}

fn main() {
    double_cps(5, |doubled| {
        add_one_cps(doubled, |result| {
            println!("Result: {}", result); // prints 11
        });
    });
}

Rust’s closures let us express continuations cleanly without needing async runtimes or macros.

Python Example

The same example can be implemented using python pretty simply.

def double_cps(x, cont):
    cont(x * 2)

def add_one_cps(x, cont):
    cont(x + 1)

double_cps(5, lambda doubled:
    add_one_cps(doubled, lambda result:
        print("Result:", result)
    )
)

Python’s lambdas work just like JavaScript’s arrow functions here. Every step is chained by explicitly passing the next operation as a continuation.

async/await in Python: CPS in the Runtime

Just like JavaScript, Python’s async def and await are built on top of generators and continuations:

import asyncio

async def get_data():
    await asyncio.sleep(1)
    return 42

async def main():
    value = await get_data()
    print("Got:", value)

asyncio.run(main())

Here, too, the interpreter:

  • Splits your function at each await
  • Stores the rest of the computation (continuation) to run later

Why Learn CPS?

Once you can see continuations, you start to understand:

  • How async runtimes work
  • How exception handling works (dual continuations!)
  • How interpreters implement tail calls and coroutines
  • How to reason about control flow in state machines

Bonus: Control Flow as a Tree

You can visualize your program’s control flow like a tree. Each continuation is a branch.

graph TD A[Start] A --> B[doubleCPS] B --> C[addOneCPS] C --> D[print]

When you await, you’re pausing on one branch — and resuming it later.

Conclusion

Continuations and CPS transform how we think about execution.

They explain:

  • Why callbacks exist
  • How async/await works under the hood
  • How control flow can be captured, resumed, or redirected

You don’t need to write a compiler to use CPS — just pass the “rest of your program” as a function.

By making the invisible visible, continuations give us precise control over what our code does next.