← Concepts & practices
Pattern Concurrency, scheduling, and delivery

Structured concurrency

If you start it here, finish it here.

A request fans out to profile, permissions, and orders. One child fails. Should the parent return immediately, wait for every child, keep partial results, or stop the siblings? Structured concurrency makes the answer a scope: the parent starts the children, owns their lifetime, joins their completion, and returns one explicit outcome.

The judgment to keep

A concurrency primitive is not a scope by itself. Name who starts the children, who waits for them, what one failure does to siblings, and how cancellation reaches the work before you call the code structured.

TypeScriptGo One request · four join policies
Start with the scope

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
structured.ts · one child
// 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.

Compare the joins

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.

JavaScript / TypeScript

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.
Go

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.
What the join promises
JoinReturnsSibling lifetime
Promise.allFirst rejection or all valuesNot canceled automatically
allSettledEvery outcomeEvery child runs to settlement
Promise.anyFirst fulfillment or all failuresLosers continue unless stopped explicitly
errgroupAfter every group function returnsContext cancels on first error; children must observe it
TypeScript · Promise joins
structured.ts · Promise joins
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)));
}
Go · joined child scope
structured.go · errgroup-style scope
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
structured.ts · owned scope
// 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.

Follow the children

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.

One parent scope

Change the join. Watch the child lifetime.

Runs a local comparison
Promise.all One child fails
01start every child
02one rejects
03join rejects; siblings may continue
Start

Call every child and keep the returned Promises before awaiting the join.

Return

Rejects as soon as one Promise rejects; it does not wait for the other Promises.

Siblings

Other Promises keep running unless you separately signal them to stop.

Scope

The aggregate settles, but the child work can outlive the caller’s await.

Use when one result is required, then add cancellation if siblings must stop.

Watch for Fail-fast result does not mean fail-fast lifetime.

The controls change a local model; they do not start real workers or network calls.
Read the complete comparisonTypeScript and Go · copy the scope boundary

One child: keep its Promise or return its Go error to the caller.

TypeScriptReading
structured.ts
// 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);
}
GoAlongside
structured.go
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
}
Name the policy first

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.

A page needs profile, orders, and permissions before it can render a complete snapshot.
A dashboard can show each card independently, even when one service is down.
Three replicas race for the first healthy answer; losing requests should stop.
A Go handler fans out reads; one failed read makes the combined response invalid.
Feedback stays on this page; it is not saved.
A production boundary

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.

Enter

Parent starts children

Keep the task handles or register every function in the scope.

React

Failure reaches siblings

Cancel through a signal or context that each child can observe.

Exit

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.

ReactAlready in your code
textbook.tsx · owned effect scope
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.

Recognize it in UI code

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.

Review cancellation propagation ↗
The parts to watch

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.

Make the call

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.

All required

Join all; cancel on invalidation.

One response is valid only when every child is ready.

Independent

Keep each outcome keyed.

Partial results are useful, but their lifetime is still owned.

First winner

Stop and join the losers.

The winning value does not finish the losing work.

Take the idea with you

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
Explore more concepts & practices →