14 - Runners: How Effects Actually Run
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:
- 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 (atryPromise, anEffect.sleep, a fiber), you must use the async runner.runSyncon async work throws. - Total vs Partial — does the runner return an
Exit<A, E>(you handle success/failure explicitly) or unwrap on success and throw on failure? TheExitvariants 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) // 42Use when:
- the Effect is entirely synchronous (no
tryPromise, nosleep, 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.
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
*Exitvariant. 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
asyncfunction 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.
Your turn `await Effect.runPromise(asyncJob)` so the test asserts the resolved value is `"ok"`. The runner returns a `Promise
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 typedCausefor 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 workawait 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.
> 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.Why it throws.
runSyncwalks 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”.
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. runSynconly for provably sync Effects. When the type system saysEffect<A, E, never>and you control the tree,runSyncis fine.runForkfor 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: whereEffect.promise/tryPromiseintroduced async into the tree. - Next,
15-data: tagged data andData.TaggedError, the building blocks for theEchannel that runners observe. - Related,
08-sync-vs-async-constructors: the matching mental model for the constructor side. - Later,
36-fibers: whatrunForkreturns and how to interact with it. - Later,
60-runtime: building a long-livedManagedRuntimethat 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/.
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.isFailureit("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 runPromiseit("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