Effection Logo

JavaScript cleans up your memory. It never cleans up your async.

Every value belongs to a scope; when the scope is gone, the runtime reclaims it. Async work belongs to nothing — it runs until it's done, whether or not the code that started it is still around.

Effection makes async work the way memory already does.

The exception

Memory is reclaimed according to the structure of your code. Asynchronous operations aren't.

A promise doesn't belong to the scope that created it. When the scope exits, the promise keeps running — and because it belongs to nothing, the runtime has no way of knowing when its lifetime is over.

So that knowledge has to come from you. The AbortSignal you thread through every call, the unsubscribe you remember, the close() in a finally, the using you declare one resource at a time — different tools, all reconstructing the same missing information: when this work should end. We spent a decade reconstructing this missing lifetime by hand.

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

But none of it would be necessary if the work simply belonged to the scope that started it — the runtime would know when it ends, the same way it knows when a value dies. That's not a workaround. It's a property a language either has or doesn't.

Effection

Effection brings it to JavaScript

Structured concurrency, built on generator functions — the same shapes you already write, with a caller that can always reclaim control. Under 6KB, dependency-free, no build step; it runs in Node, Deno, Bun and the browser, and it's been in production for five years.

800+
GitHub stars
5 yrs
maintained & evolving
100+
dependent projects
15K
weekly downloads

See for yourself

The obvious code, tested

If async work really is reclaimed when its operation exits, the defensive code should disappear. Look for the abort, the unsubscribe, the cancellation flag — they aren't there. And where teardown is genuinely needed, you declare it once and it's guaranteed to run.

timeout.ts
function* fetchUser(id) {
  return yield* race([
    fetchJSON(`/users/${id}`),
    sleep(5000),
  ]);
}

Nothing here cancels the losing request — you'd reach for an AbortController next. You don't have to. When one branch wins, the other is already halted.

parallel.ts
function* loadDashboard() {
  const [user, feed, prefs] = yield* all([
    fetchUser(),
    fetchFeed(),
    fetchPrefs(),
  ]);
  return { user, feed, prefs };
}

Promise.all rejects on the first failure but lets the other requests finish anyway. Here, one failure halts the siblings — no orphaned work.

stream.ts
function* listen(socket) {
  for (const message of yield* each(socket)) {
    yield* handle(message);
    yield* each.next();
  }
}

No socket.close(), no removeEventListener. Leave the loop — return, throw, or cancel — and the subscription closes itself.

background.ts
function* entrypoint() {
  yield* spawn(heartbeat);
  yield* serveRequests();
}

heartbeat runs for as long as entrypoint does. No handle to keep, no teardown to write — when entrypoint returns or is cancelled, it's halted with it.

database.ts
function* report() {
  const db = yield* resource(function* (provide) {
    const conn = yield* connect(DATABASE_URL);
    yield* ensure(() => conn.close());
    yield* provide(conn);
  });
  return yield* runQueries(db);
}

conn.close() is async — an await inside a plain finally is exactly what gets abandoned on cancel. The resource's teardown runs to completion when report() exits, every time.

It feels too simple to be correct.
It's correct because it's simple.

The operation tree

Every operation belongs to the one that started it

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.

The same tree, twice

Structured lifetimes unlock more than cleanup

The tree above works in both directions. Teardown flows up it — and values can flow down it.

1Halt

Cancel anything — and teardown is guaranteed

Halt an operation and everything it spawned shuts down with it — try/finally for synchronous cleanup, ensure for teardown that's itself async, both guaranteed to run. It's the one thing async/await structurally cannot do, and it's how Effection reclaims background work on its own: 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
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
}
2Context

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 — dependency injection for async, without prop-drilling. TC39's AsyncContext is chasing the same idea; Effection has it today, tied to structured lifetimes.

3Synchronous 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. The same call reads a warm cache on the current tick, and crosses into async only on a real miss.

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);
  });
}

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.

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