goto let control jump anywhere, and structured programming replaced it with
blocks: control enters at the top and leaves at the bottom, so you can reason
about a function by reading it.
Starting a task is goto all over again. Control leaves and never comes back to
a place you can see.
fetchUser(); // when does this finish? who is listening?fetchOrders(); // what if it throws?return; // …both are still runningThree specific failures
Errors vanish. Nobody awaited either call, so a rejection has no handler. Node logs an unhandled rejection and, since v15, exits the process.
Cancellation does not propagate. The caller gives up, and the tasks keep running — holding connections, writing to a closed response, spending money.
Lifetime is invisible. Reading the function tells you nothing about what is still alive when it returns.
The rule
A task may not outlive the block that started it.
fetchUser(); // no handle, no await fetchOrders(); // no handle, no await return; // …and these are still running
- ✗ swallowed — an unhandled rejection at best
- ✗ orphans keep running after you return
- ✗ you cannot tell from reading it
Two tasks with no owner. If one throws, nobody hears; if the caller returns, they carry on
using resources it thinks it released. This is the concurrency equivalent of goto.
Control cannot pass the closing brace while a child is running. If one child fails, the others are cancelled. Concurrency now nests like every other control structure.
What it looks like
// KotlincoroutineScope { val user = async { fetchUser() } val orders = async { fetchOrders() } render(user.await(), orders.await())} // ← nothing escapes this brace
// Java 21+try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var user = scope.fork(() -> fetchUser()); var orders = scope.fork(() -> fetchOrders()); scope.join().throwIfFailed(); render(user.get(), orders.get());}
// Python 3.11+async with asyncio.TaskGroup() as tg: user = tg.create_task(fetch_user()) orders = tg.create_task(fetch_orders())Also Swift’s withTaskGroup, Trio’s nurseries (which originated the idea), and
Go’s errgroup — the closest Go gets, since bare go is unstructured by design.
Cancellation becomes tractable
Because the scope owns its children, cancelling is well-defined: cancel the scope, and every descendant is cancelled with it. Cancellation follows the tree.
That is also what makes timeouts composable — a timeout is a scope that cancels itself, and everything beneath it goes too, however deep.
In plain JavaScript
There is no TaskGroup yet, but Promise.all is structured concurrency for the
happy path, and AbortController supplies the cancellation:
const controller = new AbortController();try { const [user, orders] = await Promise.all([ fetchUser({signal: controller.signal}), fetchOrders({signal: controller.signal}), ]);} finally { controller.abort(); // whatever is still running, stop it}Note the gap Promise.all leaves: it rejects on the first failure but does
not cancel the others. The finally is what closes it, and forgetting it is
the most common way JavaScript code leaks orphaned requests.