jsrosetta

Async

Structured concurrency: Promise.all and errgroup

How Node.js's Promise.all/allSettled/any combined with AbortController compare to Go's errgroup, sync.WaitGroup, and channels: cancelling sibling tasks when one fails, limiting concurrency, collecting every result, and taking the fastest one.

By
Minimum versions
Node.js ≥ 17.2Go ≥ 1.25
Verified on
Node.js 24.12.0Go 1.27.1

Node.js code is an ES module: save it as .mjs or set "type": "module" in package.json.

"Structured concurrency" is a simple rule: every child task started inside a scope has to finish before that scope ends — no task "leaks" out, and a child's failure is reported back to the parent scope. Node.js ships the Promise.all/allSettled/any/race combinators, but they only wait — they don't cancel anything: when Promise.all rejects because one promise failed, the others keep running to completion. To make sibling tasks stop, you have to pass an AbortSignal in yourself (see the cancellation post). Go has no Promise type; the equivalent patterns are assembled from goroutines, sync.WaitGroup, channels, and context — with errgroup (from golang.org/x/sync, outside the standard library) being the closest thing to "Promise.all with cancellation".

The first error cancels the siblings (Promise.all + AbortController / errgroup)

import { setTimeout as sleep } from "node:timers/promises";
 
// work simulates a job that takes `ms` milliseconds, may fail, and stops
// early when the signal is aborted.
async function work({ name, ms, fail }, signal) {
  await sleep(ms, undefined, { signal });
  if (fail) throw new Error(`${name} failed`);
  return `${name} ok`;
}
 
// all: like Promise.all, but the first error aborts the sibling tasks, and it
// only returns once EVERY task has actually stopped (like errgroup.Wait). Task i
// records its outcome in statuses[i], like the statuses slice main allocates in Go.
async function all(tasks, statuses) {
  const controller = new AbortController();
  const settled = await Promise.allSettled(
    tasks.map(async (task, i) => {
      try {
        const value = await work(task, controller.signal);
        statuses[i] = "ok";
        return value;
      } catch (err) {
        statuses[i] = err.name === "AbortError" ? "cancelled" : "failed";
        controller.abort(err); // later aborts are no-ops: reason keeps the first error
        throw err;
      }
    }),
  );
  if (controller.signal.aborted) throw controller.signal.reason;
  return settled.map((s) => s.value);
}
 
const tasks = [
  { name: "A", ms: 100 },
  { name: "B", ms: 50, fail: true }, // B fails at ~50ms, before A and C finish
  { name: "C", ms: 300 },
];
const statuses = new Array(tasks.length);
 
try {
  await all(tasks, statuses); // waits for EVERY task to stop, then throws the first error
} catch (err) {
  console.log(statuses); // → [ 'cancelled', 'failed', 'cancelled' ]
  console.log("error:", err.message); // → error: B failed
}

Limiting how many tasks run at once (mapLimit / g.SetLimit)

import { setTimeout as sleep } from "node:timers/promises";
 
// mapLimit: runs fn on every item but never more than `limit` at a time —
// Node.js has nothing built in, so build it from `limit` "workers" pulling from one queue.
async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  let failed = false;
 
  async function worker() {
    while (!failed && next < items.length) {
      const i = next++; // safe: JS is single-threaded, nothing runs between the read and the increment
      try {
        results[i] = await fn(items[i]);
      } catch (err) {
        failed = true; // other workers see this flag and stop taking new items
        throw err;
      }
    }
  }
 
  await Promise.all(Array.from({ length: limit }, () => worker()));
  return results;
}
 
let inFlight = 0;
let maxInFlight = 0;
 
const squares = await mapLimit([1, 2, 3, 4, 5, 6], 2, async (n) => {
  inFlight++;
  maxInFlight = Math.max(maxInFlight, inFlight);
  await sleep(20); // simulate I/O
  inFlight--;
  return n * n;
});
 
console.log(squares);     // → [ 1, 4, 9, 16, 25, 36 ]
console.log(maxInFlight); // → 2 (at most)

Collecting every result, errors included (Promise.allSettled / WaitGroup + errors.Join)

import { setTimeout as sleep } from "node:timers/promises";
 
async function check(name, ms, fail = false) {
  await sleep(ms);
  if (fail) throw new Error(`${name} unreachable`);
  return `${name} healthy`;
}
 
// allSettled never rejects: it waits for every promise, even if some fail
const settled = await Promise.allSettled([
  check("db", 50),
  check("cache", 20, true),
  check("queue", 30, true),
]);
 
for (const s of settled) {
  console.log(s.status, s.status === "fulfilled" ? s.value : s.reason.message);
}
// → fulfilled db healthy
// → rejected cache unreachable
// → rejected queue unreachable

Taking the first success (Promise.any / channel + cancel)

import { setTimeout as sleep } from "node:timers/promises";
 
async function fetchFrom({ name, ms, fail }, signal) {
  await sleep(ms, undefined, { signal });
  if (fail) throw new Error(`${name} down`);
  return `data from ${name}`;
}
 
async function fastest(mirrors) {
  const controller = new AbortController();
  try {
    // any: take the first SUCCESSFUL result, ignoring failures
    return await Promise.any(mirrors.map((m) => fetchFrom(m, controller.signal)));
  } finally {
    controller.abort(); // cancel the requests still running — Promise.any doesn't do this itself
  }
}
 
console.log(await fastest([
  { name: "eu", ms: 300 },
  { name: "us", ms: 100 },
  { name: "asia", ms: 50, fail: true }, // fails first, but any ignores it
])); // → data from us
 
try {
  await fastest([{ name: "eu", ms: 30, fail: true }, { name: "us", ms: 10, fail: true }]);
} catch (err) {
  console.log(err.name, err.errors.map((e) => e.message)); // → AggregateError [ 'eu down', 'us down' ]
}

Key differences

Node.js Go
First error rejects immediately (no waiting, no cancelling) Promise.all no direct equivalent
Wait for all, then return the first error Promise.allSettled + rethrow (the all function above) errgroup.Group.Wait()
First error cancels siblings AbortController passed in by hand errgroup.WithContext
Limit concurrent tasks hand-rolled (or a library like p-limit) g.SetLimit(n)
Collect every result and error Promise.allSettled sync.WaitGroup + errors.Join
First success Promise.any → AggregateError buffered channel + cancel() → errors.Join
First to settle Promise.race first value received from the channel