How do you orchestrate multiple async calls that need to fan out and then fan back in (like `Promise.all`)?
Fan-out dispatches independent async calls at once; fan-in waits for and combines them — and the real design question is what happens when only some of them succeed.
LEARN BY DOINGPut your intuition to work3 steps
How do you orchestrate multiple async calls that need to fan out and then fan back in (like `Promise.all`)?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The Pattern
"Fan-out, fan-in" describes any point in a system where you dispatch several independent asynchronous operations concurrently (fan-out) and then wait for all of them to finish before continuing, typically combining their results into one value (fan-in). It shows up constantly in backend-for-frontend and API gateway layers: assembling a product page means calling an inventory service, a pricing service, and a reviews service — none of which depend on each other's results — and none of it should happen sequentially if the three calls could run at the same time.
#Promise.all Fails Fast — Sometimes That's Wrong
Promise.all (and its equivalents in other languages — Kotlin's awaitAll, a plain asyncio.gather in Python) rejects as soon as any one of the input promises rejects, and it discards whatever the other promises would have resolved to. That's the right behavior when every result is actually required to proceed — there's no point building a response if one required piece is missing. It's the wrong behavior when a partial result is still useful: a product page that can render with a "reviews temporarily unavailable" placeholder is strictly better for the user than a product page that fails to load at all because the reviews service had a blip.
#Promise.allSettled for Partial-Failure Tolerance
Promise.allSettled always resolves — never rejects — once every input promise has either fulfilled or rejected, and it hands back the outcome of each one individually so you can decide, call by call, what a failure should mean for the combined result:
interface ServiceResult<T> {ok: boolean;value?: T;error?: string;}async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {let timer: ReturnType<typeof setTimeout>;const timeout = new Promise<T>((_, reject) => {timer = setTimeout(() => reject(new Error('timed out after ' + ms + 'ms')), ms);});try {return await Promise.race([promise, timeout]);} finally {clearTimeout(timer!);}}async function getProductPage(productId: string) {const [inventory, pricing, reviews] = await Promise.allSettled([withTimeout(inventoryService.get(productId), 300),withTimeout(pricingService.get(productId), 300),withTimeout(reviewsService.get(productId), 300),]);return {inventory: inventory.status === 'fulfilled' ? inventory.value : null,pricing: pricing.status === 'fulfilled' ? pricing.value : null,reviews: reviews.status === 'fulfilled'? reviews.value: { items: [], note: 'temporarily unavailable' },degraded: [inventory, pricing, reviews].some((r) => r.status === 'rejected'),};}
Two details matter here beyond allSettled itself. First, each call is individually wrapped with its own timeout — without that, one slow dependency (not even a failing one, just a slow one) would stall the entire fan-in past whatever latency budget the endpoint has, because allSettled still waits for every promise to settle one way or another. Second, the degraded flag surfaces the partial failure to the caller and to monitoring, rather than silently returning a page that looks complete but is missing data — an interviewer will often specifically probe whether you'd let a partial failure pass silently.
#Bounding the Fan-Out
Fan-out is safe when the number of concurrent calls is small and fixed — three services for one page. It stops being safe the moment the fan-out size is data-driven: fetching detail records for 5,000 order IDs by firing 5,000 requests at once will very likely overwhelm the downstream service (or your own outbound connection pool) even if each individual call is fine in isolation. The fix is a concurrency limiter — a small fixed number of workers pulling from a shared queue of work, rather than one Promise per item:
async function mapWithConcurrency<T, R>(items: T[],limit: number,fn: (item: T) => Promise<R>,): Promise<R[]> {const results: R[] = new Array(items.length);let nextIndex = 0;async function worker(): Promise<void> {while (nextIndex < items.length) {const current = nextIndex++;results[current] = await fn(items[current]);}}const workerCount = Math.min(limit, items.length);await Promise.all(Array.from({ length: workerCount }, () => worker()));return results;}
Each worker pulls the next index atomically (JavaScript is single-threaded, so incrementing nextIndex between await points can't race), writes into its own slot of the results array, and the whole thing still resolves once every item has been processed — fan-out bounded to limit concurrent calls, fan-in via the shared array.
#Go's Take: Explicit Fail-Fast vs. Explicit Partial Tolerance
Go doesn't have a built-in Promise.all, which forces the fail-fast/partial-tolerance decision to be explicit rather than implicit in which function you call. golang.org/x/sync/errgroup gives you fail-fast semantics close to Promise.all: the first goroutine to return an error cancels a shared context and Wait() returns that error, discarding the rest. For partial-failure tolerance, you instead capture a per-item result (including its own error) and use a plain sync.WaitGroup, so no single failure short-circuits the group:
type result struct {value stringerr error}func fetchAllDetails(ids []string) []result {results := make([]result, len(ids))var wg sync.WaitGroupfor i, id := range ids {wg.Add(1)go func(i int, id string) {defer wg.Done()ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)defer cancel()value, err := fetchDetail(ctx, id)results[i] = result{value: value, err: err}}(i, id)}wg.Wait()return results}
Each goroutine writes only to its own index of results, so the fan-in needs no mutex — there is no shared-write contention because every writer owns a disjoint slice element. This is the allSettled shape: nothing here cancels a sibling goroutine when one fails, and the caller decides afterward, per item, what a non-nil err should mean for the final response.