Structured Concurrency: The Programming Model Fixing Async Code


Structured Concurrency: The Programming Model Fixing Async Code

Most async code has a hidden problem: tasks can outlive the code that spawned them. You fire off a goroutine, a Task, or a Future, and there is no built-in guarantee it finishes before the surrounding function returns, its errors propagate correctly, or it gets cancelled when it’s no longer needed. This is the root cause of leaked goroutines, half-executed cleanup logic, and subtle races that only appear under load.

Structured concurrency is the idea that concurrent tasks should obey the same lifetime rules as other resources in your program. Like a local variable, a task should be created in a scope and cleaned up when that scope exits—no exceptions.

The Problem with Unstructured Async

Fire-and-forget concurrency is easy to write and hard to operate. Consider a request handler that spawns background work: if the handler returns before the background task completes, you have no way to know whether the task succeeded, panicked, or is still running three hours later consuming memory. Multiply this across a service under traffic and you get a slow bleed of leaked resources.

Cancellation compounds the problem. In most async runtimes, cancellation is manual and advisory. The programmer has to thread cancellation tokens down through every layer of abstraction and check them at the right moments. Miss one and your “cancelled” operation keeps running silently in the background, holding connections and locks. Error handling is equally fragile: if a background task panics or returns an error after its parent has already moved on, that error vanishes unless you’ve explicitly wired up a monitoring channel.

None of these problems are obscure edge cases. They’re the routine cost of working with unstructured concurrency at scale.

How Structured Concurrency Works

The core primitive is a task group (sometimes called a nursery or scope). You open a task scope, spawn tasks inside it, and the scope does not close until every task inside it has finished—either by completing normally or by being cancelled. The current task blocks at the closing boundary.

This one rule buys you several properties for free. Errors from any child task propagate to the parent, since the parent is waiting at the boundary. When any child fails, the scope can automatically cancel the remaining siblings before surfacing the error. Resources acquired inside the scope—connections, file handles, locks—are not visible outside it, so cleanup is local and predictable. The call stack is no longer a fiction: when a task panics, its stack trace reflects its actual chain of invocation back to the task group that spawned it.

This is the same insight that structured programming applied to control flow in the 1960s. goto let you jump anywhere; the compiler had no way to reason about lifetimes or resource ownership. Replacing it with loops and functions created a tractable structure that compilers, linters, and humans could analyze. Structured concurrency does the same thing for task lifetimes.

Where It Shows Up

The model has found its way into most modern languages:

  • Kotlin has coroutineScope and supervisorScope, which enforce structured lifetimes as part of its coroutines library. Cancellation and error propagation are automatic within a scope.
  • Swift introduced async let and TaskGroup in its concurrency model, making task lifetimes explicit and compiler-checked.
  • Java added StructuredTaskScope in recent releases as part of Project Loom, making structured concurrency available on top of virtual threads.
  • Python’s Trio library pioneered the nursery concept and influenced much of the broader design thinking on this topic.
  • Rust’s async ecosystem is converging on similar patterns, though the ownership model provides additional compile-time guarantees about what crosses task boundaries.

Go is a notable holdout—goroutines remain unstructured—though the errgroup package and conventions around context.Context approximate some of the benefits manually.

The Tradeoff Worth Knowing

Structured concurrency imposes a constraint: tasks cannot outlive their parent scope. This genuinely limits some patterns, particularly long-running background work that is intentionally detached from any request lifecycle. Systems still need an escape hatch for daemon tasks, and most implementations provide one. The key is that the escape hatch is explicit and visible rather than the default behavior.

For the 90% case—handling requests, running parallel queries, coordinating a set of async operations with a shared deadline—structured concurrency eliminates an entire class of bugs without requiring extra code. The discipline it imposes is the same discipline you’d want to apply anyway; it just stops being optional.