A child should not become invisible because its parent returned.
The most common fan-out shape is deceptively small: start a few independent reads, wait for
a combined answer, and render. The difficult case begins when one read fails or the caller
goes away. A rejected Promise.all can leave sibling requests running. A goroutine
launched without a group can outlive the handler that started it.
Structured concurrency is a lifetime rule with four parts: child tasks are created inside a scope, the scope keeps ownership, the scope waits for child completion, and failure or cancellation has a stated sibling policy. The result can still be all-or-nothing, partial-success, or first-winner; the important part is that no child is left behind by accident.
“Fail fast” describes when a result is returned. “Structured” also asks whether the work has finished and who cleaned it up.
Read one child as a valueTypeScript · the parent keeps the work handle
// One child is a value: returning its Promise keeps the work attached to the caller that awaits it.
export function runOne(job: Job, work: Work): Promise<string> {
return work(job);
} Returning the Promise gives the caller something it can await. The same ownership idea scales to a group: keep every child inside the parent’s scope and keep the join at the boundary that can decide what the combined outcome means.
Same fan-out, different answers to “who waits?”
Promise.all, Promise.allSettled, and Promise.any are useful result combinators. They do not all provide structured lifetimes. Go’s errgroup.WithContext joins its registered functions and cancels the derived context
on the first error, but each function still has to observe that context.
Promise joins
Choose fail-fast, every-outcome, or first-winner result semantics.
- Start
- Call each child and retain its Promise.
- Return
- The combinator decides when the aggregate settles.
- Own
- Add AbortSignal and cleanup when the lifetime matters.
errgroup-style scope
Register functions, cancel a derived context on error, and wait for every child.
- Start
- Call group.Go for every child.
- Return
- Wait joins the group and returns the first error.
- Own
- Children select on context cancellation.
| Join | Returns | Sibling lifetime |
|---|---|---|
Promise.all | First rejection or all values | Not canceled automatically |
allSettled | Every outcome | Every child runs to settlement |
Promise.any | First fulfillment or all failures | Losers continue unless stopped explicitly |
errgroup | After every group function returns | Context cancels on first error; children must observe it |
export function runAll(jobs: Job[], work: Work): Promise<string[]> {
return Promise.all(jobs.map((job) => work(job)));
}
export async function runAllSettled(jobs: Job[], work: Work): Promise<SettledResult[]> {
const results = await Promise.allSettled(jobs.map((job) => work(job)));
return results.map((result, index) =>
result.status === 'fulfilled'
? { id: jobs[index].id, status: result.status, value: result.value }
: { id: jobs[index].id, status: result.status, reason: message(result.reason) }
);
}
export function runAny(jobs: Job[], work: Work): Promise<string> {
return Promise.any(jobs.map((job) => work(job)));
} func WithContext(parent context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancel(parent)
return &Group{cancel: cancel}, ctx
}
func (g *Group) Go(fn func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := fn(); err != nil {
g.once.Do(func() {
g.firstErr = err
g.cancel()
})
}
}()
}
func (g *Group) Wait() error {
g.wg.Wait()
g.cancel()
return g.firstErr
}
func runStructured(parent context.Context, jobs []Job) error {
group, ctx := WithContext(parent)
for _, job := range jobs {
job := job
group.Go(func() error { return runOne(ctx, job) })
}
return group.Wait()
} Read the explicit TypeScript scopePromise.all plus abort and a final allSettled
// Promise.all gives a result join, but this wrapper also owns sibling cancellation and cleanup.
export async function runStructured(jobs: Job[], work: Work): Promise<string[]> {
const controller = new AbortController();
const tasks = jobs.map((job) => work(job, controller.signal));
try {
return await Promise.all(tasks);
} catch (error) {
controller.abort();
await Promise.allSettled(tasks);
throw error;
}
} The wrapper makes the missing pieces visible. It aborts siblings after the first failure
and then awaits allSettled so cleanup finishes before the parent rethrows. The
work functions must actually listen to the signal for the cancellation part to mean anything.
Change the join. Watch who is still running.
Choose a join strategy and change the child outcomes. The lab keeps the same three children so the only moving parts are return timing, sibling policy, and whether the parent has closed the scope.
Change the join. Watch the child lifetime.
Call every child and keep the returned Promises before awaiting the join.
Rejects as soon as one Promise rejects; it does not wait for the other Promises.
Other Promises keep running unless you separately signal them to stop.
The aggregate settles, but the child work can outlive the caller’s await.
Watch for Fail-fast result does not mean fail-fast lifetime.
Read the complete comparisonTypeScript and Go · copy the scope boundary
One child: keep its Promise or return its Go error to the caller.
// One child is a value: returning its Promise keeps the work attached to the caller that awaits it.
export function runOne(job: Job, work: Work): Promise<string> {
return work(job);
} func runOne(ctx context.Context, job Job) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if job.Fail {
return fmt.Errorf("%s failed", job.ID)
}
return nil
} Choose the result contract, then close the scope.
The right combinator follows from what the caller needs: every value, every outcome, or the first useful winner. If the request can end early or one failure makes siblings irrelevant, add an owned cancellation path and a join for the children that remain.
Make the parent’s return prove that the work is done.
For an all-required account snapshot, the request handler owns the child reads. Start them together, pass the request context or signal, and do not send a response until the chosen scope has joined. When one read fails, cancel cooperative siblings, wait for their cleanup, and translate the result at the boundary.
For independent cards, allSettled may be the correct result policy. That does not
mean every card belongs to the same lifetime forever: if the user navigates away, the page scope
still needs to abort the reads it owns. For a first-winner race, cancel the losers after the winner
is selected and await their cleanup before discarding the race object.
Parent starts children
Keep the task handles or register every function in the scope.
Failure reaches siblings
Cancel through a signal or context that each child can observe.
Join closes the scope
Return only after the children have completed or been safely stopped.
An effect owns one account snapshot, aborts child requests on cleanup, and ignores stale results.
import { useEffect, useState } from 'react';
type DashboardState =
| { status: 'loading' }
| { status: 'ready'; profile: unknown; orders: unknown }
| { status: 'error'; message: string };
async function getJson(url: string, signal: AbortSignal) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`${response.status} from ${url}`);
return response.json();
}
export function Dashboard({ accountId }: { accountId: string }) {
const [state, setState] = useState<DashboardState>({ status: 'loading' });
useEffect(() => {
const controller = new AbortController();
let current = true;
async function loadSnapshot() {
setState({ status: 'loading' });
try {
const [profile, orders] = await Promise.all([
getJson(`/api/accounts/${accountId}`, controller.signal),
getJson(`/api/accounts/${accountId}/orders`, controller.signal)
]);
if (current) setState({ status: 'ready', profile, orders });
} catch (error) {
if (!current || controller.signal.aborted) return;
setState({
status: 'error',
message: error instanceof Error ? error.message : String(error)
});
}
}
void loadSnapshot();
return () => {
current = false;
controller.abort();
};
}, [accountId]);
return <DashboardView state={state} />;
}
declare function DashboardView(props: { state: DashboardState }): JSX.Element;
Build services or UIs?The scope is already in your request handler or effect.
Where it already is in your components
A loader that awaits several fetches, a React effect that returns cleanup, and a Go
handler that calls group.Wait() all define a boundary around unfinished work. Review
whether every child is actually inside it.
When you have to own it
When work can fail, outlive the request, or consume scarce resources, make sibling policy and cleanup part of the function contract. A scope that cannot say who waits is not finished.
An effect is a small structured scope.
Effect starts
Every request started by an effect belongs to that render’s lifetime.
Cleanup cancels
Abort the signal and prevent a stale result from updating the next render.
State joins
Only publish ready or error state after the owned children have reached the chosen boundary.
The helper cannot own a child you launch outside it.
Promise.all rejects before siblings finish
The joined Promise rejects on the first rejection. The other operations may still be running, and their later failures can become unhandled or mutate state after the caller moved on. Add a shared signal and wait for cleanup if that lifetime matters.
allSettled waits, but it does not cancel
allSettled is a useful join for independent outcomes. It is not a resource policy;
an operation can remain expensive until settlement, and the caller still needs to abort work
that is no longer relevant.
Promise.any leaves losers behind
The first fulfillment resolves the race, but slower requests do not disappear. Cancel and join losers when the race owns sockets, CPU, or a visible loading state.
errgroup still needs cooperative children
An errgroup-style scope cancels its context, not arbitrary instructions. A child that
ignores ctx.Done(), waits on an uninterruptible operation, or starts another
goroutine outside the group can still keep the parent from closing cleanly.
Choose the smallest scope that tells the whole truth.
Use Promise.all for an all-required snapshot, allSettled for
useful independent outcomes, and Promise.any for a first-winner race. Add the missing
lifetime owner when those helpers can otherwise leave work behind. Use an errgroup-style scope
when Go children share one request lifetime, first failure should cancel siblings, and the parent
can wait for the group.
Join all; cancel on invalidation.
One response is valid only when every child is ready.
Keep each outcome keyed.
Partial results are useful, but their lifetime is still owned.
Stop and join the losers.
The winning value does not finish the losing work.
Make unfinished work belong somewhere.
Structured concurrency is the habit of making task lifetime visible. A Promise combinator can choose the result semantics; a context or signal can carry cancellation; a group can join child completion. The design is complete only when those choices line up at one parent boundary.
- Why
- Keep child work attached to the request that can finish it.
- What
- Start, cancel, join, and return at one explicit scope.
- Constraint
- Children must observe cancellation and finish their cleanup before the scope closes.
- Fallback
- Explicit coordination adds code. Keep it at the request boundary and test the failure paths, not only the happy join.
- Reconsider when
- The work is durable, queued, or intentionally outlives the request.
Connections to follow nextRelated lessons
- Cancellation propagation when a stop request must reach the children.
- Bounded parallelism when the scope starts more children than a dependency can take at once.
- Backpressure and queues when the work should outlive the request under a durable owner.