37 - Interruption
Effect’s concurrency model is structured: when a fiber is cancelled, the work it was running stops, and any resources it acquired are released. The mechanism is interruption.
You’ve already met it implicitly — when Effect.race picks a winner, the loser is interrupted; when a timeout fires, the inner work is interrupted. This step makes interruption the topic so you can use it explicitly.
What you’ll work through
index.test.ts covers five beats in order: Fiber.interrupt, Effect.ensuring, observing interruption via Cause.hasInterruptsOnly, Effect.onInterrupt, and Effect.uninterruptible.
By the end you should be able to distinguish cancellation from failure in an Exit, attach the right cleanup combinator for the case at hand, and protect short atomic regions with uninterruptible without strangling cancellation.
Mental Model
parent ├── fork ──► child (running) │ │ │ ▼ │ Effect.interrupt │ │ │ ▼ │ finalizers run (LIFO) │ │ │ ▼ │ Exit.Failure(Cause.interrupt) ▼parent observes the exit via Fiber.awaitThree guarantees:
- Interruption is cooperative but immediate. A running computation is
suspended at the next opportunity (a
yield*, aflatMap, asleep). - Finalizers run before the fiber dies. Acquired resources are released
in reverse order via
Effect.ensuring/Effect.acquireRelease(covered in step 46 Scope). - An interrupted fiber’s
Exitis aFailurewhose cause isInterrupt— not a regular E-channel value. You can distinguish it withCause.hasInterruptsOnly.
Effect.uninterruptible declares a region that ignores interruption requests — use it sparingly, only when you need an operation to complete atomically (release a lock, flush a buffer).
Fiber.interrupt — cancelling a running fiber
import { Effect, Fiber } from "effect"
const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild(Effect.sleep("1 hour")) yield* Fiber.interrupt(fiber) return yield* Fiber.await(fiber)})A forked fiber can be interrupted at any time by its parent. The fiber suspends, any in-flight finalizers run, and the resulting Exit describes the interruption.
Long-running effects (a sleep, a tryPromise waiting on a slow request) are interrupted immediately — Effect does not wait for them to finish. Synchronous CPU loops with no yield* between iterations are the one shape that interruption can’t preempt; insert Effect.yieldNow() if you need a cancellation point.
Rule of thumb.
Fiber.interruptis itself an Effect — and itawaits the fiber’s exit. By the time it returns, all finalizers have finished and the fiber is gone. Reach forFiber.interruptForkif you want fire-and-forget cancellation.
Your turn fork a long sleep with `Effect.forkChild`, call `Fiber.interrupt(fiber)`, and read the `Exit` from `Fiber.await(fiber)`. The exit will be a `Failure` whose cause is `Interrupt`, not a typed `E`.
Effect.ensuring — finalizers fire on interruption
import { Effect } from "effect"
const flush: Array<string> = []
const work = Effect.sleep("1 hour").pipe( Effect.ensuring(Effect.sync(() => flush.push("released"))),)Effect.ensuring(release) attaches a finalizer to a workflow. The finalizer runs on success, on failure, and on interruption — exactly the cleanup guarantee try/finally gives you in plain JS, but composable.
This is the foundation of Scope (step 46): an Effect can acquire a resource and the runtime guarantees release.
Rule of thumb. Reach for
ensuringfor all-paths cleanup (the JSfinallyshape). When you want different behaviour on the interrupted path, useonInterrupt(two beats down). When you want different behaviour on the success path, useEffect.tap.
Your turn wrap the long-sleeping work with `Effect.ensuring(Effect.sync(() => flush.push("released")))`, interrupt the fiber, and assert `flush` contains `"released"` — proof the finalizer ran even though the work was cancelled.
Observing interruption — Cause.hasInterruptsOnly
import { Effect, Exit, Cause } from "effect"
const exit = await Effect.runPromise(Fiber.await(fiber))if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) { // The fiber was cancelled, not failed with a typed error.}An interrupted fiber’s Exit is a Failure, but its Cause is Interrupt, not Fail. Don’t reach for catch to handle it — that catches failures.
Use Cause.hasInterruptsOnly (or Effect.onInterrupt) when you specifically need to detect cancellation.
Exit.Failure ├─ Cause.Fail(e) ← catch / catchTag react ├─ Cause.Die(defect) ← unrecoverable, see step 48 └─ Cause.Interrupt(id) ← only Cause.hasInterruptsOnly detects thisYour turn call `Effect.runPromise(Fiber.await(fiber))` to get the `Exit`, then guard with `Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)` to confirm cancellation rather than a typed E-channel failure.
Effect.onInterrupt — cleanup that only runs on cancellation
import { Effect } from "effect"
const auditing = (work: Effect.Effect<void>) => work.pipe( Effect.onInterrupt(() => Effect.logWarning("cancelled — auditing")), )onInterrupt is the narrower sibling of ensuring: it fires only when the workflow is interrupted, not on success or failure. Useful for cancellation metrics, audit logs, or compensating actions you want only on the cancelled path.
| Combinator | Success | Failure | Interrupt |
|---|---|---|---|
Effect.tap |
✓ | — | — |
Effect.onError |
— | ✓ | — |
Effect.onInterrupt |
— | — | ✓ |
Effect.ensuring |
✓ | ✓ | ✓ |
Rule of thumb. Pick the narrowest combinator that fits — it makes intent obvious at the call site. A cancellation-only metric should announce itself as
onInterrupt, not hide inside anensuringwith anifoverExit.
Your turn attach `Effect.onInterrupt(() => Effect.sync(() => ...))` to the workflow and run two cases — one that completes normally (no side effect) and one that's interrupted (side effect fires). The contrast between the two assertions is the lesson.
Effect.uninterruptible — a region that cannot be cancelled
import { Effect } from "effect"
const critical = Effect.gen(function* () { yield* acquireLock() yield* doDelicateWork() yield* releaseLock()}).pipe(Effect.uninterruptible)Sometimes a region must complete atomically — release a lock, flush a buffer, commit a transaction. Wrap it in Effect.uninterruptible and interruption requests are deferred until the region exits.
Use sparingly: a long uninterruptible region is a denial-of-service against your own cancellation system. Prefer Effect.uninterruptibleMask when you need to poke an interruptible hole inside a larger uninterruptible region.
Your turn wrap a region in `uninterruptible`, interrupt the fiber, and prove the region still completed before the fiber died.
A Full Example
import { Effect, Fiber, Cause, Exit } from "effect"
const audit: Array<string> = []
const work = Effect.gen(function* () { audit.push("started") yield* Effect.sleep("1 hour") audit.push("finished") // never reached if interrupted}).pipe( Effect.ensuring(Effect.sync(() => audit.push("released"))), Effect.onInterrupt(() => Effect.sync(() => audit.push("cancelled"))),)
const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild(work) yield* Effect.sleep("10 millis") yield* Fiber.interrupt(fiber) return yield* Fiber.await(fiber)})
const exit = await Effect.runPromise(program)// audit === ["started", "cancelled", "released"]// Exit.isFailure(exit) === true// Cause.hasInterruptsOnly(exit.cause) === trueensuring covers all exit paths. onInterrupt narrows to the cancellation path only.
The exit is a Failure with an Interrupt cause — not a regular typed E.
Common Pitfalls
1, Treating interruption as a failure. catch and catchTag ignore interruption. If you need to react to cancellation, use onInterrupt or inspect the Cause with Cause.hasInterruptsOnly.
2, Long uninterruptible regions. They defeat structured concurrency. Keep them as small as the atomic operation requires.
3, Forgetting that finalizers can themselves be interrupted. By default, they are not — Effect makes finalizer execution uninterruptible so cleanup runs to completion. Long finalizers can delay fiber death.
4, Assuming tryPromise can be cancelled mid-promise. Effect can interrupt the waiting, but it cannot cancel the underlying Promise (JS doesn’t allow that). For true cancellation pass an AbortSignal down.
Rules of thumb
- Cleanup goes in
ensuring— covers success, failure, interruption. onInterruptis for cancellation-only side effects like audit logs.- Reach for
uninterruptibleonly when the operation must be atomic. Smaller is safer. - An interrupt is not a failure. It’s its own exit kind. Use
Cause.hasInterruptsOnlyto detect it.
confirm the exit is a `Failure` with an `Interrupt` cause. - [ ] Add `Effect.ensuring` to the work and confirm the finalizer fires even though the fiber was cancelled. - [ ] Branch on `Cause.hasInterruptsOnly` to distinguish cancellation from a typed failure — `catch` won't help here. - [ ] Use `Effect.onInterrupt` for cancellation-only cleanup and prove it does *not* fire on the happy path. - [ ] Wrap a critical region in `Effect.uninterruptible` and confirm the region completes before the fiber dies.
See Also
- Previous,
36-fibers:Effect.forkChild,Fiber.await, and friends — the fiber operations interruption acts on. - Next,
38-semaphore: bounded concurrency. Semaphore permits release correctly on interruption thanks to the finalizer machinery here. - Related,
35-concurrency: whereraceandtimeoutalready use interruption implicitly. - Later,
46-scope: the resource model built onensuring—acquireReleaseis its named form.
Run the tests: pnpm test 37-interruption
The full test suite
The index.test.ts for this lesson, verbatim from steps/37-interruption/.
import { it, expect } from "vite-plus/test"import { Effect, Fiber, Exit, Cause } from "effect"
// =============================================================================// 37 - Interruption// =============================================================================//// Cancelling a fiber. Finalizers run, the exit is an Interrupt cause (not a// failure), and uninterruptible regions defer cancellation.
// @beat// id: fiber-interrupt// shape: one-todo// title: Fiber.interrupt cancels a running fiber// teaches: A forked fiber can be interrupted by its parent; the interrupt is observed via Fiber.await as a Failure exit with an Interrupt cause.// hint: yield* Effect.forkChild(longWork), then yield* Fiber.interrupt(fiber)it("Fiber.interrupt cancels a long-running fiber", async () => { const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild(Effect.sleep("1 hour")) /* @stub */ yield* Fiber.interrupt(fiber) /* @end */ return yield* Fiber.await(fiber) })
const exit = await Effect.runPromise(program) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) }})// @end-beat
// @beat// id: finalizers-run// shape: one-todo// title: Effect.ensuring finalizers fire on interruption// teaches: A finalizer attached with Effect.ensuring runs on success, on failure, and on interruption — the foundation of safe resource handling.// hint: work.pipe(Effect.ensuring(Effect.sync(() => audit.push("released"))))it("ensuring finalizer runs when the fiber is interrupted", async () => { const audit: Array<string> = []
const work = Effect.sleep("1 hour").pipe( /* @stub */ Effect.ensuring(Effect.sync(() => audit.push("released"))) /* @end */, )
const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild(work) yield* Effect.sleep("5 millis") yield* Fiber.interrupt(fiber) yield* Fiber.await(fiber) })
await Effect.runPromise(program) expect(audit).toEqual(["released"])})// @end-beat
// @beat// id: interrupted-exit// shape: one-todo// title: Distinguish interruption from a typed failure// teaches: catch catches failures but not interrupts; use Cause.hasInterruptsOnly to react specifically to cancellation.// hint: Cause.hasInterruptsOnly(exit.cause)it("Cause.hasInterruptsOnly distinguishes interruption from failure", async () => { const program = Effect.gen(function* () { const fiber = yield* Effect.forkChild(Effect.sleep("1 hour")) yield* Fiber.interrupt(fiber) return yield* Fiber.await(fiber) })
const exit = await Effect.runPromise(program) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const interrupted = /* @stub */ Cause.hasInterruptsOnly(exit.cause) /* @end */ expect(interrupted).toBe(true) }})// @end-beat
// @beat// id: on-interrupt// shape: one-todo// title: Effect.onInterrupt — cleanup that only runs on cancellation// teaches: onInterrupt fires only when the workflow is interrupted, not on success or failure. Use it for cancellation-specific side effects.// hint: work.pipe(Effect.onInterrupt(() => Effect.sync(() => audit.push("cancelled"))))it("onInterrupt fires only on the cancelled path", async () => { const audit: Array<string> = []
const cancelling = Effect.sleep("1 hour").pipe( /* @stub */ Effect.onInterrupt(() => Effect.sync(() => { audit.push("cancelled") }), ) /* @end */, )
const okWork = Effect.succeed(1).pipe( Effect.onInterrupt(() => Effect.sync(() => { audit.push("should-not-fire") }), ), )
// Success path — onInterrupt does not fire. await Effect.runPromise(okWork) expect(audit).toEqual([])
// Cancelled path — onInterrupt fires. await Effect.runPromise( Effect.gen(function* () { const fiber = yield* Effect.forkChild(cancelling) yield* Effect.sleep("5 millis") yield* Fiber.interrupt(fiber) yield* Fiber.await(fiber) }), ) expect(audit).toEqual(["cancelled"])})// @end-beat
// @beat// id: uninterruptible// shape: one-todo// title: Effect.uninterruptible defers cancellation until the region exits// teaches: An uninterruptible region completes before any pending interrupt is delivered — use for atomic operations like lock release or buffer flush.// hint: critical.pipe(Effect.uninterruptible)it("uninterruptible region completes before interruption is delivered", async () => { const audit: Array<string> = []
const critical = Effect.gen(function* () { audit.push("started") yield* Effect.sleep("20 millis") audit.push("finished") }).pipe(/* @stub */ Effect.uninterruptible /* @end */)
await Effect.runPromise( Effect.gen(function* () { const fiber = yield* Effect.forkChild(critical) yield* Effect.sleep("5 millis") yield* Fiber.interrupt(fiber) yield* Fiber.await(fiber) }), )
// Even though we interrupted mid-region, both side effects landed. expect(audit).toEqual(["started", "finished"])})// @end-beat