Home / Part 2 / 2.2 Async

Section 2.2

Async

Section 2.1's loop ran a whole tree, but every node finished instantly. Real programs wait — on a network call, a timer, a file. We add the one node that can take time, and the one trick that makes it work: the loop can stop, and start again later.

Methods: async · tryPromise

Follow along with the code

This section's runtime is self-contained. Read it, run example.ts with bun, and step through it with a debugger as you read.

Why this exists: the loop can't just wait

When the loop hits a node that takes 50ms, it has a problem. It can't sit and spin — that freezes the thread. It can't block — JavaScript is single-threaded, so blocking freezes everything too. So it does the only thing left: it stops, and arranges to be restarted when the answer arrives.

That's the entire idea. A slow node says "I don't have a value yet — here's how to reach me when I do," and the loop walks away until it's reached.

The Async node

{ _op: "Async"; register: (resume: (effect) => void) => void }

It holds one function, register. The runtime calls it and hands it a resume callback. Two sides, two owners:

It's callback-based because the bottom layer of JavaScript is callback-based — setTimeout, socket.on, fs.readFile, even new Promise. async is where our toy meets that layer, so it speaks its language.

A small concrete use — a value that arrives after 100ms:

const delayed = async((resume) => {
  setTimeout(() => resume(succeed("done!")), 100) // start the work; resume when it lands
})

How the loop pauses and resumes

case "Async": {
  const resume = (next) => {
    current = toPrimitive(next) // the result, as an effect
    step()                      // re-enter the loop
  }
  node.register(resume)         // start the work
  return                        // LEAVE the loop. the thread is now free.
}

The order matters: build resume, call register(resume) (this starts the work now), then return — the loop exits, the call stack empties, the thread is free. Later the work finishes and calls resume(succeed(value)), which sets current and calls step() again.

step() running hit Async → register() → return thread free · timer / fetch in flight resume(succeed(v)) → step() step() continues current · stack · value · failure live in the closure, not on the call stack so when the loop walks away, its state is frozen, not lost — that frozen state is the fiber's memory.
"Pause" is just return out of step. "Resume" is just calling step again. Everything in between survives because the registers live in a closure.
Why coloring goes away

You never wrote await. The loop stepped out on a slow node and came back. A sync node and an async node sit in the same chain; the only difference is that the async one makes the loop leave and re-enter. So sync and async compose the same way, with nothing marked in the code.

tryPromise is built on async

No special promise handling — it's a thin wrapper. .then is the register: it starts the promise and arranges to call resume on settle. A resolve becomes succeed; a reject runs your catch to make a typed error and becomes fail:

const tryPromise = (options) =>
  async((resume) => {
    options.try().then(
      (value) => resume(succeed(value)),
      (error) => resume(fail(options.catch(error)))
    )
  })

So when you use tryPromise (or, in Part 1, Effect.tryPromise), you're using this callback dance — it's just written for you. You hand over () => fetch(...) and never see resume.

Why running now returns a Promise

In 2.1, the runner handed back the answer immediately, because every node finished at once. Now a node can pause — so the runner might be parked when you call it. That's why the main runner is now runPromise: it gives you a Promise up front and resolves it from inside resume, whenever that fires.

const runSyncExit = (effect) => {
  let result
  unsafeRun(effect, (exit) => { result = exit })
  if (result === undefined) throw new Error("async — use runPromise")
  return result
}

If the tree is fully sync, unsafeRun finishes before it returns, so result is set. If it parks on an Async node, unsafeRun returns with result still unset — so we throw. This is exactly the real runSync vs runPromise split: runPromise always works, runSync is an opt-in "I promise this is sync" that fails loudly if you're wrong.

The only place sync vs async shows up

Once, at the edge, when you run. Everywhere else, sync and async are the same Effect.

Where this maps in real Effect

async is OP_ASYNC in the Effect source — same shape, with two additions we'll get to in 2.5: the register also receives an AbortSignal, and it can return a canceler, both used for interruption. The real tryPromise is the same .then dance we wrote.