02·01Runners

Up to here we've been calling `Effect.runPromise(...)` whenever we need to *actually run* an Effect, but it has been ambient — never the topic. This step makes the runners the topic.

14 - Runners: How Effects Actually Run

Previous · Next

Up to here we’ve been calling Effect.runPromise(...) whenever we need to actually run an Effect, but it has been ambient — never the topic. This step makes the runners the topic.

There are four to know, and picking the wrong one either crashes at the seam between Effect and the outside world or silently hides a failure.

What you’ll work through

index.test.ts contains six beats: one per runner (runSync, runSyncExit, runPromise, runPromiseExit, runFork) plus a mistake-first beat showing runSync failing on async work and the fix.

By the end you should be able to read any Effect’s type and pick the right runner in one decision — sync vs async on one axis, total vs partial on the other.


Mental Model

total (returns Exit) partial (throws on failure)
───────────────────── ───────────────────────────
sync (caller) Effect.runSyncExit Effect.runSync
async (caller) Effect.runPromiseExit Effect.runPromise
fire-and-forget Effect.runFork (returns a RuntimeFiber)

Two axes:

  1. Sync vs Async — does your caller want to block on the result (runSync) or get a Promise (runPromise)? If the underlying Effect contains any async work (a tryPromise, an Effect.sleep, a fiber), you must use the async runner. runSync on async work throws.
  2. Total vs Partial — does the runner return an Exit<A, E> (you handle success/failure explicitly) or unwrap on success and throw on failure? The Exit variants are the safer default at boundaries.

A fifth, runFork, is for “run this in the background and give me a fiber I can join or interrupt.”


Effect.runSync — synchronous, throws on failure

import { Effect } from "effect"
// ┌─── number
// ▼
const compute = Effect.sync(() => 6 * 7)
const answer = Effect.runSync(compute) // 42

Use when:

  • the Effect is entirely synchronous (no tryPromise, no sleep, no fiber),
  • and you’re confident it won’t fail (or you’re happy to let a throw escape).

It is the right runner for short, infallible computations at the very edge of your program — REPLs, tests, simple scripts, the inside of a useMemo. On failure, runSync re-throws whatever was in E, wrapped in a FiberFailure if the cause is a defect or interrupt.

> property. `Effect` does *not* guarantee sync — a layer > upstream may have introduced `Effect.sleep`. If `runSync` ever throws > `AsyncFiberException`, the fix is to switch to `runPromise`, not to > retry.

Your turn call `Effect.runSync(compute)` so the test asserts `answer === 42`. The runner unwraps the success value into a plain `number`.


Effect.runSyncExit — synchronous, returns Exit

import { Effect, Exit, Cause } from "effect"
// ┌─── Exit<number, DivideByZero>
// ▼
const exit = Effect.runSyncExit(divide(10, 0))
if (Exit.isFailure(exit)) {
const error = Cause.findErrorOption(exit.cause)
// error._tag === "Some" → error.value is DivideByZero
}

Same as runSync but failures come back as an Exit.Failure instead of being thrown. The Exit wraps the full Cause — typed E failures, defects, and interrupts all coexist on the same object.

Use this at boundaries where you want to observe the result without control flow leaving via an exception.

Rule of thumb. Boundary code that has to render an error message, persist a failure, or return an HTTP status almost always wants the *Exit variant. A bare throw across the seam is hostile to the caller.

Your turn call `Effect.runSyncExit(divide(10, 2))` for the success path and `Effect.runSyncExit(divide(10, 0))` for the failure path, then branch on `Exit.isFailure` to read the tagged `DivideByZero` error inside.


Effect.runPromise — asynchronous, rejects on failure

import { Effect } from "effect"
// ┌─── Promise<Response>
// ▼
const job = Effect.tryPromise({ try: () => fetch("/api"), catch: () => "boom" })
const response = await Effect.runPromise(job)

Use when:

  • the Effect contains any async work, or
  • the caller is in an async function and prefers a Promise interface.

On failure, the returned Promise rejects with the E-channel value (or a FiberFailure wrapping the full Cause for defects/interrupts). This is the right runner at the seam between Effect and async/await-style code — Express handlers, Next.js route handlers, anything you .then from.

> is an unhandled promise rejection — same hazard as raw async/await. If > you can't guarantee a handler upstream, reach for `runPromiseExit` > (next beat).

Your turn `await Effect.runPromise(asyncJob)` so the test asserts the resolved value is `"ok"`. The runner returns a `Promise` you can hand straight to any async caller.


Effect.runPromiseExit — asynchronous, returns Exit

import { Effect, Exit } from "effect"
// ┌─── Promise<Exit<A, E>> — never rejects
// ▼
const exit = await Effect.runPromiseExit(job)
if (Exit.isFailure(exit)) {
// Cause is available on exit.cause — defects and all
}

The async sibling of runSyncExit. The returned Promise never rejects — it always resolves with an Exit, even for defects and interrupts.

Use it when you’re crossing into JS land but want to handle success and failure explicitly without try/catch.

Rule of thumb. This is the default at long-lived boundaries — HTTP servers, queue workers, CLI mains. You get one Promise that always resolves, one branch for success, one for failure, and a typed Cause for diagnosing whatever happened.

Your turn `await Effect.runPromiseExit(fail)` and branch on `Exit.isFailure` to confirm the `DivideByZero` tag landed in the cause. There is no `try/catch` in this test — the whole point is that the seam stays expression-shaped.


The wrong-runner mistake — runSync on async work

import { Effect } from "effect"
const asyncJob = Effect.promise(() => Promise.resolve("ok"))
// ❌ throws AsyncFiberException — runSync cannot wait for a promise.
Effect.runSync(asyncJob)
// ✅ use runPromise for async work
await Effect.runPromise(asyncJob)

runSync does not know how to wait for a microtask. The moment an await appears anywhere inside the Effect tree, runSync throws an AsyncFiberException.

The fix is mechanical: switch to runPromise.

Why it throws. runSync walks the description on the calling stack and finishes before returning. There is no event loop tick to wait on. The exception is the runtime’s way of saying “this Effect cannot be finished without yielding control to the JS host”.

> with `R = never` says nothing about sync vs async. If you ship a library > that adds an `Effect.sleep` retry to a previously-sync code path, > downstream `runSync` callers crash at the next deploy.

Your turn read the assertion that `Effect.runSync(asyncJob)` throws (left in place to make the bug observable), then replace the second stub with `await Effect.runPromise(asyncJob)` to confirm the right runner resolves the same Effect cleanly.


Effect.runFork — fire-and-forget

import { Effect, Fiber } from "effect"
const job = Effect.sleep("1 second").pipe(Effect.as("done"))
const fiber = Effect.runFork(job)
// ... do other things ...
const exit = await Fiber.await(fiber)

runFork spawns the Effect as a fiber and returns immediately. The caller can join it later (Fiber.await returns an Effect<Exit<A, E>>; Fiber.join returns an Effect<A, E> that re-raises failures), interrupt it (Fiber.interrupt), or simply walk away.

This is the runner for long-lived processes: HTTP server roots, background workers, daemons, or any Effect you want to run next to the caller, not before it.

Rule of thumb. Forking without ever joining or interrupting is a resource leak. Result store the fiber and clean it up explicitly, or attach it to a scope (Effect.forkScoped — step 36) so the runtime can interrupt it when the scope closes.

Your turn call `Effect.runFork(work)` to spawn the fiber, then `await Effect.runPromise(Fiber.await(fiber))` (already in place) to collect the final `Exit` and confirm the success value is `"forked"`.


A Full Example

import { Effect, Exit } from "effect"
// Inside a Node entrypoint:
const main = Effect.gen(function* () {
const result = yield* Effect.tryPromise({
try: () => fetch("/api/health"),
catch: () => "unreachable" as const,
})
return result.status
})
// Async boundary, total — observe success or failure, exit with a code.
const exit = await Effect.runPromiseExit(main)
process.exit(Exit.isSuccess(exit) ? 0 : 1)

The runner sits at the edge between Effect and the JS host. Inside, everything is composition.

Outside, the runner produces a value, a Promise, an Exit, or a Fiber — pick the one that matches the seam.


Common Pitfalls

1, runSync on async Effects. Throws AsyncFiberException. Any tryPromise / sleep / fork in the tree forces runPromise.

2, Unhandled rejections at the seam. runPromise rejects on failure. If you don’t await and don’t .catch(), you get an unhandled promise rejection.

Prefer runPromiseExit when you can’t guarantee a handler upstream.

3, Running the same Effect in a loop expecting accumulated state. Each runner invocation is a fresh execution. State (Ref, etc.) is shared within a run, not across runs (covered in step 39).

4, Forking without joining or interrupting. A forked fiber outlives its caller. If you never join or interrupt it, you’ve created a resource leak.


Rules of thumb

  • Boundary code defaults to runPromiseExit. Total, async, explicit — no throws crossing the seam, no surprise rejections.
  • runSync only for provably sync Effects. When the type system says Effect<A, E, never> and you control the tree, runSync is fine.
  • runFork for long-lived work. Anything that should outlive a single request/response cycle.

`run-sync-exit`, `run-promise`, `run-promise-exit`. - [ ] Read the `run-sync-on-async` mistake-first beat. The throw is the point — observe it, then add the right runner. - [ ] Finish with `run-fork` — note the return is a `RuntimeFiber`, not an `Exit`. Step 36 picks up the fiber operations.

See Also

  • Previous, 13-async-operations: where Effect.promise / tryPromise introduced async into the tree.
  • Next, 15-data: tagged data and Data.TaggedError, the building blocks for the E channel that runners observe.
  • Related, 08-sync-vs-async-constructors: the matching mental model for the constructor side.
  • Later, 36-fibers: what runFork returns and how to interact with it.
  • Later, 60-runtime: building a long-lived ManagedRuntime that owns many runs.

Run the tests: pnpm test 14-runners


The full test suite

The index.test.ts for this lesson, verbatim from steps/14-runners/.

index.test.ts
import { it, expect } from "vite-plus/test"
import { Effect, Exit, Fiber, Data, Cause } from "effect"
// =============================================================================
// 14 - Runners
// =============================================================================
//
// Two axes: sync vs async (caller waits or gets a Promise) and total vs
// partial (returns Exit vs throws/rejects on failure). Plus runFork for
// fire-and-forget.
class DivideByZero extends Data.TaggedError("DivideByZero")<{}> {}
const divide = (a: number, b: number) =>
b === 0 ? Effect.fail(new DivideByZero()) : Effect.succeed(a / b)
// @beat
// id: run-sync
// shape: one-todo
// title: Effect.runSync — synchronous, throws on failure
// teaches: runSync executes a fully synchronous Effect and returns A directly. Failures throw.
// hint: Effect.runSync(Effect.sync(() => 42))
it("runSync returns the success value for a synchronous Effect", () => {
const compute = Effect.sync(() => 6 * 7)
const answer = /* @stub */ Effect.runSync(compute) /* @end */
expect(answer).toBe(42)
})
// @end-beat
// @beat
// id: run-sync-exit
// shape: one-todo
// title: Effect.runSyncExit — synchronous, returns Exit
// teaches: runSyncExit returns Exit<A, E> so callers can branch on success/failure without try/catch.
// hint: Effect.runSyncExit(divide(10, 0)) — branch on Exit.isFailure
it("runSyncExit returns an Exit and never throws", () => {
const ok = /* @stub */ Effect.runSyncExit(divide(10, 2)) /* @end */
expect(Exit.isSuccess(ok)).toBe(true)
if (Exit.isSuccess(ok)) expect(ok.value).toBe(5)
const bad = /* @stub */ Effect.runSyncExit(divide(10, 0)) /* @end */
expect(Exit.isFailure(bad)).toBe(true)
if (Exit.isFailure(bad)) {
const error = Cause.findErrorOption(bad.cause)
expect(error._tag).toBe("Some")
if (error._tag === "Some") {
expect(error.value._tag).toBe("DivideByZero")
}
}
})
// @end-beat
// @beat
// id: run-promise
// shape: one-todo
// title: Effect.runPromise — asynchronous, rejects on failure
// teaches: runPromise is the right runner whenever the Effect contains async work; it returns a Promise that rejects on failure.
// hint: await Effect.runPromise(asyncEffect)
it("runPromise awaits an async Effect and resolves with A", async () => {
const asyncJob = Effect.promise(() => Promise.resolve("ok"))
const result = /* @stub */ await Effect.runPromise(asyncJob) /* @end */
expect(result).toBe("ok")
})
// @end-beat
// @beat
// id: run-promise-exit
// shape: one-todo
// title: Effect.runPromiseExit — asynchronous, returns Exit
// teaches: runPromiseExit is the async sibling of runSyncExit; the returned Promise never rejects — it resolves with an Exit.
// hint: const exit = await Effect.runPromiseExit(...)
it("runPromiseExit resolves with an Exit instead of rejecting", async () => {
const fail = Effect.fail(new DivideByZero())
const exit = /* @stub */ await Effect.runPromiseExit(fail) /* @end */
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.findErrorOption(exit.cause)
expect(error._tag).toBe("Some")
if (error._tag === "Some") {
expect(error.value._tag).toBe("DivideByZero")
}
}
})
// @end-beat
// @beat
// id: run-sync-on-async
// shape: mistake-first
// title: runSync cannot wait for async — it throws
// teaches: runSync on an Effect containing any async work (promise, sleep, fork) throws AsyncFiberException at runtime. Use runPromise instead.
// hint: first half shows the throw; second half uses runPromise
it("runSync on async work throws AsyncFiberException", async () => {
const asyncJob = Effect.promise(() => Promise.resolve("ok"))
// ❌ Wrong runner — throws.
expect(() => Effect.runSync(asyncJob)).toThrow()
// ✅ Right runner — awaits the work.
const result = /* @stub */ await Effect.runPromise(asyncJob) /* @end */
expect(result).toBe("ok")
})
// @end-beat
// @beat
// id: run-fork
// shape: one-todo
// title: Effect.runFork — fire-and-forget
// teaches: runFork spawns the Effect as a fiber and returns immediately. Fiber.await collects the eventual Exit; Fiber.interrupt cancels it.
// hint: const fiber = Effect.runFork(work); const exit = await Effect.runPromise(Fiber.await(fiber))
it("runFork returns a fiber that can be awaited later", async () => {
const work = Effect.succeed("forked").pipe(Effect.delay("1 millis"))
const fiber = /* @stub */ Effect.runFork(work) /* @end */
const exit = await Effect.runPromise(Fiber.await(fiber))
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(exit.value).toBe("forked")
})
// @end-beat
← PreviousAsync OperationsNext →Data