Web Development · Software Architecture
Structured Concurrency Explained: Scoping Tasks So They Can't Leak
A background task that outlives the function that spawned it is a real, recurring production bug. Structured concurrency fixes it by tying a task's lifetime to a scope. Here's the pattern across Python, Kotlin, Swift, and Java.
Prathviraj Singh
8 min read
Sponsored
A service spawns a background task to write an audit log entry, doesn’t hold on to the reference, and moves on to handle the next request. Three weeks later, someone notices audit logs have gaps. The task was failing on a foreign key constraint every time a particular user type hit that code path, and the exception went nowhere. No log line, no alert, no stack trace in Sentry. The task just died quietly, because nothing in the program was ever going to look at it again.
This is not an exotic bug. It’s the default failure mode of “fire and forget” concurrency, and it shows up in Python services, Go daemons, Java thread pools, and Node event handlers alike. Structured concurrency is the name for the fix: a concurrent task’s lifetime is tied to the lexical scope that created it, so the parent cannot exit while a child is still running, and an exception in a child has nowhere to go except back to the parent. It’s not a new runtime or a new syntax feature so much as a discipline that several languages have now built directly into their standard libraries.
The unstructured version of the bug
Here’s the pattern in Python, using the classic create_task call that’s been in asyncio since Python 3.4:
import asyncio
async def handle_request(payload):
# Fire and forget: nothing holds a reference to this task
asyncio.create_task(write_audit_log(payload))
return {"status": "accepted"}
async def write_audit_log(payload):
# If this raises, the exception is attached to the task object
# and surfaces only if something later calls task.result() or
# the task is garbage collected with a pending exception, which
# asyncio logs as "Task exception was never retrieved" at best.
await db.insert("audit_log", payload)
handle_request returns before write_audit_log has even started running. If the insert fails, nobody catches it. If the process shuts down mid-request, the task is abandoned with no cleanup. The task’s lifetime has no relationship to the function that created it at all: it’s a stray.
The same shape shows up outside Python. A Go goroutine started with a bare go and no sync.WaitGroup or errgroup can keep running after its caller returns, silently, with a panic inside it crashing the whole process if unrecovered. A Java new Thread(runnable).start() with no join() behaves the same way. None of these are exotic mistakes: they’re the natural result of concurrency primitives that let you start something without requiring you to track it.
The structured version
Structured concurrency closes that gap by making the scope itself responsible for the wait. Python 3.11 added asyncio.TaskGroup to do exactly this:
import asyncio
async def handle_request(payload):
async with asyncio.TaskGroup() as tg:
tg.create_task(write_audit_log(payload))
tg.create_task(update_metrics(payload))
# Execution only reaches here after both tasks finish.
# If either raised, the TaskGroup cancels the other and re-raises
# (wrapped in an ExceptionGroup) here, in the caller's frame.
return {"status": "accepted"}
The block cannot exit until both tasks are done. A failure in write_audit_log cancels update_metrics and the exception surfaces exactly where you’d expect it, at the async with block, not in a task object nobody looks at. This repo already has a deep dive on asyncio in production, Python asyncio production pitfalls, which covers blocking calls, cancellation timing, and event loop gotchas in more depth. TaskGroup is one tool among several discussed there; this post is about the broader pattern it’s an instance of.
The same idea in Kotlin
Kotlin coroutines were built around structured concurrency from the start, using coroutineScope:
suspend fun handleRequest(payload: Payload): Response = coroutineScope {
launch { writeAuditLog(payload) }
launch { updateMetrics(payload) }
// coroutineScope suspends here until both launched coroutines complete.
// A failure in either cancels its sibling and propagates out of this call.
Response(status = "accepted")
}
Every coroutine builder in Kotlin (launch, async, coroutineScope) requires a CoroutineScope to run in, and that scope is what enforces the wait. There’s no equivalent of Python’s old bare create_task in idiomatic Kotlin; you’d have to go out of your way with GlobalScope.launch to get the unstructured behavior, and the standard library documentation actively discourages it.
Swift’s two flavors
Swift gives you two ways to express the same guarantee, depending on whether you know the number of child tasks up front. For a fixed, known set:
func handleRequest(payload: Payload) async throws -> Response {
async let auditResult = writeAuditLog(payload)
async let metricsResult = updateMetrics(payload)
// Both `async let` bindings are awaited implicitly at the end of scope.
// If this function returns or throws before both complete, Swift
// awaits (and if needed cancels) them for you first.
_ = try await (auditResult, metricsResult)
return Response(status: "accepted")
}
For a dynamic number of children, withTaskGroup (or withThrowingTaskGroup when children can fail) is the direct equivalent of Python’s TaskGroup:
func processAll(items: [Item]) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
for item in items {
group.addTask { try await process(item) }
}
// Implicitly waits for every added task before returning.
// The first thrown error cancels remaining tasks and propagates.
try await group.waitForAll()
}
}
Java: the same guarantee, still in preview
Java’s StructuredTaskScope (part of Project Loom’s work on virtual threads) applies the identical rule to threads instead of coroutines:
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow())) {
Subtask<String> audit = scope.fork(() -> writeAuditLog(payload));
Subtask<String> metrics = scope.fork(() -> updateMetrics(payload));
scope.join(); // blocks until both subtasks finish, or one fails
return new Response("accepted", audit.get(), metrics.get());
}
Worth being direct about where this stands: StructuredTaskScope has gone through several rounds of preview since its first incubation in JDK 19 and, as of JDK 26 (JEP 525), it’s on its sixth preview, with a seventh already proposed for JDK 27. The API shape above matches the current preview; expect it to keep shifting slightly before it’s finalized. If you adopt it today, pin your JDK version and read the release notes before upgrading.
Where JavaScript falls short
TypeScript and JavaScript don’t have a native structured concurrency primitive, and it’s worth saying so plainly rather than pretending Promise.all covers it. Promise.all waits for a group of promises, but the promises themselves start running the instant they’re created, detached from any scope, and nothing forces a caller to wait for one just because it exists. The common approximation combines AbortController for cancellation signaling with Promise.all or Promise.allSettled for waiting:
async function handleRequest(payload) {
const controller = new AbortController();
try {
await Promise.all([
writeAuditLog(payload, { signal: controller.signal }),
updateMetrics(payload, { signal: controller.signal }),
]);
} catch (err) {
controller.abort(); // ask the other operation to stop
throw err;
}
return { status: "accepted" };
}
This gets you closer, but every function in the chain has to opt in to checking signal.aborted or handling AbortSignal itself; there’s no runtime enforcement. A setTimeout or a fetch call started without threading the signal through will keep running regardless of what the caller does. It’s a convention, not a guarantee, which is the exact distinction structured concurrency is meant to erase.
Where the idea came from
The term traces to Martin Sústrik, who described it in 2016 while working on the C library libdill, arguing that concurrent primitives should compose the same way structured control flow (blocks, loops, function calls) replaced goto decades earlier. Nathaniel J. Smith picked up the idea in 2018 with the widely cited essay “Notes on structured concurrency, or: Go statement considered harmful,” and built it directly into trio, a Python async library that predates and directly inspired the design of asyncio.TaskGroup. Trio calls its scope a “nursery,” which is arguably a better name than “task group,” since it captures the idea that nothing born inside it is allowed to wander off unsupervised.
What to check in your own code
If you’re auditing an existing codebase, the smell is consistent across languages: any call that starts concurrent work and returns a handle you don’t immediately do something with. asyncio.create_task() with no stored reference, go func() with no wait group entry, new Thread().start() with no join(), a setTimeout inside an async handler with no way to cancel it on early return. Each of these is a task with no owner, and a task with no owner is a task whose failure you will find out about from a symptom, not a stack trace.
The fix is almost never a rewrite. It’s replacing the spawn call at each of those sites with the language’s scoped equivalent, one call site at a time, starting with whichever background tasks touch anything that matters if it silently fails: writes, payments, anything with a side effect you’d notice missing. The scope doesn’t make concurrency simpler in the sense of doing less work. It makes it honest about what’s still running and who’s responsible for finding out when it isn’t.
Frequently asked questions
- What is structured concurrency in simple terms?
- It's a rule that says a concurrent task's lifetime can never extend past the code block that created it. If a function spawns three background tasks, that function cannot return until all three are done, cancelled, or have failed. It's the concurrent equivalent of a local variable going out of scope: nothing about the child can outlive the parent's block.
- How is structured concurrency different from just using async/await?
- async/await controls how a single chain of calls yields control. It says nothing about what happens to a task you spawn and don't wait on. asyncio.create_task() returns immediately and the task keeps running independently, which is unstructured, even though your code is full of await. Structured concurrency adds an explicit scope (a TaskGroup, a coroutineScope, a StructuredTaskScope) that forces the wait to happen.
- Does structured concurrency mean I can never run background jobs?
- No. It means background jobs need an owner. A long-lived worker task is fine as long as something with a matching lifetime (a supervisor scope that runs for the life of the process, for example) is responsible for holding, monitoring, and eventually cancelling or awaiting it. What structured concurrency rules out is a task with no owner at all.
- Is Java's StructuredTaskScope ready for production use?
- As of JDK 26 it's still a preview feature (JEP 525, the sixth preview iteration since it started as an incubator in JDK 19), which means it requires the --enable-preview flag and its API can still change between releases. Teams building on it should expect to update call sites when it graduates to a permanent feature, likely in JDK 27 or later.
- Why doesn't JavaScript have structured concurrency built in?
- Promises in JavaScript are eager and detached from any enclosing scope by design; once you create one, nothing forces anyone to await it. AbortController gives you a way to signal cancellation into a group of operations, and Promise.all/Promise.allSettled give you a way to wait for a group, but neither one gives you the automatic child-to-parent exception propagation or scope-bound lifetime that TaskGroup or coroutineScope enforce.
Sources
Sponsored
More from this category
More from Web Development
R.01 Exactly-Once vs At-Least-Once Delivery: What Your Message Queue Actually Guarantees
R.02 Optimistic vs Pessimistic Locking: Which One Your Database Actually Needs
R.03 Soft Delete vs Hard Delete: What Actually Breaks When You Pick Wrong
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored