Effection Logo

Every JS library author has shipped this bug

You wrote the cleanup. It still didn't run.

Your helper acquires a lock, runs the caller's work, releases it in a finally. The code is correct — until the caller is cancelled half a second early. Then control never comes back, the finally never runs, and the lock leaks.

protect.ts
// A helper you ship. Looks bulletproof.
async function protect(work) {
  const lock = await acquireLock();
  try {
    await work();
  } finally {
    release(lock);   // …unless work() never returns
  }
}

The caller hits CTRL-C at 9.5s of a 10s job. Control is trapped past the await; the finally never runs; the lock leaks. And it isn't just locks — every subscription, child process, socket and timer your library starts can outlive the moment it mattered.

Why you can't just be more careful

It isn't your code. It's the await boundary.

Once execution crosses an await, the caller can no longer regain control until the promise settles. It's baked into the runtime, not a lack of discipline — a property we dig into as the await event horizon.

What you expect
What JavaScript guarantees
The caller can stop the work
The callee decides when it ends
Work ends when it's no longer needed
Work ends when its innermost promise settles

You already know the workarounds — and why they fall short. try/finally waits on the wrong side of the boundary; AbortSignal is a request the callee can ignore. Neither hands control back to the caller.

Flip it, and the problem dissolves: let the caller decide when its children end — and always be able to reclaim them. That property has a name.

JavaScript has async/await.
It doesn't have structured concurrency.

A proven model

Every other major language already has it.

The idea is proven: Kotlin, Swift and Python already made structured concurrency first-class in the language. JavaScript hasn't — but you don't have to wait for it. You can have the same guarantees today, at the lowest total cost of any option.

Bundle
< 7KB gzipped
Learning
no new mental model
Build
none — pure ESM
Runtime
Node · Deno · Bun · browser

Why wait for the platform to catch up when the price of adopting it now is this small?

Effection

Effection brings it to JavaScript

Structured concurrency, built on generator functions — the same shape as async/await, but a caller can always reclaim control. Here is protect() again, now leak-proof:

protect.ts
function* protect(work) {
  const lock = yield* acquireLock();
  try {
    yield* work();
  } finally {
    release(lock);   // halt forces control back — always runs
  }
}
800+
GitHub stars
5 yrs
maintained & evolving
100+
dependent projects
15K
weekly downloads

The operation tree

Halt forces control back — teardown always runs

entrypoint()✓ torn downstartServer()✓ torn downwatchFiles()✓ torn downsocket✓ torn downdb pool✓ torn downfs handle✓ torn down
halt signal ↓teardown ↑

The halt signal travels down the tree (green); teardown then completes bottom-up, rolling up each branch independently (amber) — a parent finishes only once all its children have, each ensure running as its operation exits.

Three superpowers you get for free

Structured lifetimes unlock more than cleanup

Once every operation has a well-defined lifetime, a few things that were painful in plain async become trivial.

1Halt

Cancel anything — and teardown is guaranteed

Any operation can be halted, and halting a parent shuts down everything it spawned. Synchronous cleanup in try/finally just works; for teardown that must itself do async work, ensure guarantees it runs.

  • The thing async/await structurally cannot do.
  • This is what makes Effection leak-proof by construction.
  • Background tasks are reclaimed automatically when the foreground result is done — strict structured concurrency.
halt.ts
const task = yield* spawn(function* () {
  yield* ensure(() => closeConnection());  // async teardown
  yield* suspend();
});

yield* task.halt();   // stops the child and all it spawned
cache.ts
function* getUser(id) {
  // yield* resolves on THIS tick when warm, and
  // upgrades to an async fetch only on a miss.
  // (await would cost a tick even on a hit.)
  return yield* cache.read(id, function* () {
    return yield* fetchUser(id);
  });
}
2Synchronous by default

Stay synchronous until you actually need async

A single yield* can resolve synchronously or asynchronously — where await always defers to the next tick. So the exact same call reads a warm cache on the current tick, and only crosses into async on a real miss.

  • One uniform yield* — sync on a hit, async on a miss.
  • No wasted microtask on the hot path, and no race between concurrent callers.
3Context

Share state down the call tree, no prop-drilling

Set a value once; read it anywhere below. It's scoped to the operation, so it disappears when the operation exits. TC39's AsyncContext is chasing the same idea — Effection has it today, tied to structured lifetimes.

  • The primitive you didn't know you wanted — and can use now.
  • Perfect for dependency injection, request scopes, connections, loggers and tokens.
context.ts
const Token = createContext("token");

yield* Token.set("abc-123");
yield* fetchUser();            // never passes the token

function* fetchUser() {
  const token = yield* Token.expect();   // reads from context
}

What it all adds up to

Complex async, as simple as complex sync

This isn't only about cancellation. Once every operation has a lifetime, the way you compose them changes too — you nest ordinary loops and functions as deep as you like, and none of it needs lifecycle bookkeeping. It's the same deal garbage collection gives you for memory: write the logic, and the runtime reclaims what you've stopped using.

server.ts
function* handleConnection(socket) {
  // each handles messages concurrently — no spawn needed
  for (const message of yield* each(socket)) {
    const updates = yield* subscribe(message.channel);
    yield* spawn(() => forward(client, updates));
    yield* process(message);
    yield* each.next();
  }
}

Look for the code that shuts down those listeners and background processes when the socket closes. It isn't there.

A small leap from async/await

If you know async/await, you already know most of it

For the serial code that is most of what you write, adopting Effection is a near-mechanical translation. Same functions, loops and try/finally — you mostly swap await for yield*.

async / await
Effection
async function () {}
function* () {}
await task
yield* task
Promise.all([ … ])
all([ … ])
Promise.race([ … ])
race([ … ])
try / finally (sync cleanup)
try / finally — unchanged

Resources, streams & the rest — see the full Async Rosetta Stone

Get structured concurrency in JavaScript today.

Leak-proof cleanup, real cancellation and scoped context — in one dependency-free package.

$npm install effection