Why JavaScript Needs Structured Concurrency

Taras Mankovski
February 6, 2026
You hit Ctrl-C. The CLI exits. And yet the port is still bound.
Or you navigate away in the browser, and a request you no longer care about keeps running anyway — burning battery, holding sockets, and calling callbacks into code that has already moved on.
This is the part of JavaScript async we all learn to tolerate: work that outlives the scope (the lifetime boundary) that started it.
Structured programming was created to rein in a similar kind of chaos in the
70s. We take our structured constructs for granted now, but before them it was
the Wild West: crashes, leaks, infinite loops, and programs that were hard to
reason about. People reached for goto, control flow jumped across the page,
and the shape of the program stopped matching how it ran. Structured concurrency
is the re-application of that same knowledge to concurrency — binding the
lifetime of concurrent work to the structure of the program.
For the longer historical perspective, Nathaniel J. Smith's Notes on structured concurrency (or: Go statement considered harmful) is the classic.
Here's what I mean: if I start some concurrent work inside a block of code, that work should have a clear owner and a natural lifetime, and it should reliably clean up when that block is done. The picture at the top shows the difference: on the left, work escapes the function boundary and leaks. On the right, everything lives inside the scope that started it — and when that scope ends, everything stops.
Now here's where the shape of the program stops matching how it runs. Effection is one way to bring that guarantee back to JavaScript — but first, it helps to name the failure mode clearly.
Where Async Breaks JavaScript
In synchronous JavaScript, lifetimes are boring in a good way: a function runs
to completion unless it throws, and finally {} runs when control leaves the
try block. When the function returns, the work is over.
Async changes that. Once you start async work by creating a Promise, the caller has two bad options: await it (possibly forever), or move on while the work keeps running past the caller's lifetime boundary. Either way, there's nothing built in that can halt it and force cleanup to run — unless you explicitly thread cancellation through the call chain.
Here's the shape of the problem in plain async code:
async function run() {
const server = startServer(); // spawns a child process that binds a port
try {
await fetch("https://example.com/slow");
} finally {
server.kill(); // only runs if run() unwinds
}
}
// hard exit: parent dies, child keeps running
process.on("SIGINT", () => process.exit(0));
run();
When async/await was standardized, it didn't come with parent-to-child control
— no built-in halt, no guaranteed cleanup — unless every function in the chain
opts in (e.g. via AbortSignal). In practice, finally {} stops being a
reliable place to put cleanup for the async work you kicked off — because that
work isn't bound to the scope that created it, and you can't force it to unwind.
Cancellation becomes a convention rather than a guarantee. You end up threading
cancellation signals through layers of code just to get something resembling
interruption. Leaked timers, ports, and listeners become common failure modes.
It's the Wild West of the 70s all over again — just async this time.
This broken model has been with us for so long that most developers have learned to live with it — accepting that closing a CLI leaves orphaned processes, that async work keeps running in the browser long after it's needed, chipping away at performance. Fixing it feels like it requires a whole different paradigm — Observables, maybe — so we reach for workarounds and move on.
For the deeper explanation, see The Await Event Horizon and The Heartbreaking Inadequacy of Abort Controller.
The fix isn't more convention — it's the missing guarantee.
What Effection Changes
Effection makes async code feel like it has the same structure that our synchronous code has had for decades. The structured concurrency part comes down to two guarantees:
- No operation runs longer than its parent.
- Every operation exits fully (cleanup runs).
That's the difference between "the port is still bound" and "cleanup actually runs."
It's quickly becoming the default shape of concurrency: Kotlin, Swift, Python
3.11, and
Java 21
all ship it, and Go has libraries like
conc that approximate it.
Here's what that looks like:
import { main, sleep, spawn } from "effection";
await main(function* () {
yield* spawn(function* () {
try {
yield* sleep(30_000); // long-running timer
} finally {
console.log("timer cleaned up");
}
});
yield* sleep(1000);
console.log("main done");
// when main exits, the spawned task is halted
// and its finally {} block runs — guaranteed.
});
And main() takes care of the ugly host integration: in Node/Deno it traps
SIGINT/SIGTERM, and in the browser it shuts down on unload, so your scopes
halt and finally {} blocks run instead of being skipped by hard exits.
You still reach for if, for, while, and try/catch/finally. The main
difference is that where you would normally write await, you use yield*
inside a generator function. If you're coming from async/await, the mapping is
in the Async Rosetta Stone. For the mental model,
see Thinking in Effection. For spawning
specifically, see spawn.
Structured Concurrency for JavaScript
Structured concurrency isn't so much new as it is overdue: it's the missing guarantee that makes async behave like you already expect. Effection stays small because it doesn't ask you to change how you write programs; it fills in what the runtime doesn't guarantee by default so shutdown becomes normal control flow instead of a special case. When the program ends — Ctrl-C, SIGTERM, navigation, cancellation — your concurrent work halts cleanly instead of leaking past the scope that started it.
Effection is not a large library. It is small and simple by design, so that async can be bulletproof and still feel normal.