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 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.
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.
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.
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.
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.
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.
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
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.
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.
const task = yield* spawn(function* () {
yield* ensure(() => closeConnection()); // async teardown
yield* suspend();
});
yield* task.halt(); // stops the child and all it spawnedfunction* 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);
});
}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.
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.
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.
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*.
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.