30% offEnding soon

Promise.all in JavaScript: An Interview Guide

20 min read

It fits independent operations when every result is required, but its ordering and early rejection rules create common interview traps.

Promise.all accepts an iterable and returns a promise that fulfills with input-ordered values after every input fulfills, or rejects when it observes an input rejection.

Many inputs, one promiseABCone aggregatepromiseall fulfill[A,B,C]one rejectsreason
Many inputs produce one aggregate outcome.

What Promise.all Does

The method accepts an iterable, such as an array or Set. It returns a promise with two possible outcomes:

  • It fulfills with an array after every input fulfills.
  • It rejects when the aggregate observes an input rejection.

The fulfillment array follows the iterable's input order. Completion order does not rearrange it.

A common use is requesting independent resources and destructuring the combined result:

async function fetchJson(url) {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error('A request failed');
  }

  return response.json();
}

async function loadInterviewPage() {
  const [question, attempts] = await Promise.all([
    fetchJson('/api/question'),
    fetchJson('/api/attempts'),
  ]);

  return { question, attempts };
}

The two fetch calls happen before Promise.all receives their promises. The combinator then waits for both. The same distinction matters when discussing Promise.all as a coding question: it coordinates existing inputs, but it does not call the functions that produce them.

Each fetchJson call validates and parses its response independently, so parsing one response can overlap the other request. Both results are required, so Promise.all matches the requirement.

How Promise.all Preserves Order

Input order and completion order are different concepts. Promise.all records the position of each input and stores its fulfillment value at that position.

This example deliberately makes the second promise finish first:

function delay(value, milliseconds) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), milliseconds);
  });
}

Promise.all([
  delay('first', 30),
  delay('second', 5),
]).then((values) => {
  console.log(JSON.stringify(values));
});
["first","second"]

The value "second" becomes available earlier, but it still belongs at index 1. The aggregate cannot fulfill until both positions have values.

Completion order ≠ input orderinput 0“first”input 1“second”finishes firstindex 0“first”index 1“second”
Finishing first does not mean becoming the first result.

A useful interview model has three parts:

  1. Assign an index to each input.
  2. Store each fulfillment value at its assigned index.
  3. Count how many inputs have not yet recorded a fulfillment value.

When the remaining count reaches zero, the implementation resolves with the stored array. This model explains why pushing values as promises finish is wrong. results.push(value) records completion order instead of input order.

Each input is normalized through the promise constructor's resolve operation. For native Promise.all, that means promises, thenables, and plain values can appear together. A thenable is an object with a callable then property.

const thenable = {
  then(resolve) {
    resolve('from thenable');
  },
};

Promise.all([7, Promise.resolve(8), thenable]).then((values) => {
  console.log(JSON.stringify(values));
});
[7,8,"from thenable"]

An empty iterable returns an already fulfilled promise whose value is an empty array. A nonempty iterable with no pending promises still fulfills asynchronously.

console.log('before');

Promise.all([1, 2]).then((values) => {
  console.log(JSON.stringify(values));
});

console.log('after');
before
after
[1,2]

The state of the returned promise and the time at which a reaction runs are separate concerns. This distinction prevents an incorrect implementation from calling user code synchronously.

Concurrency, Parallelism, and Sequential Await

Concurrency means multiple operations can make progress during overlapping periods. Parallelism means work executes at the same instant. Promise.all does not guarantee parallel execution, and the promise objects themselves do not perform the underlying work.

For native promises, the producing function normally starts or schedules the relevant operation when it is called; Promise.all does not call that function. Normalizing a thenable can access and invoke its then, however, so a lazy thenable may start work at that point. In this sequential version, the second call does not happen until the first result arrives:

async function loadSequentially() {
  const question = await fetchQuestion();
  const attempts = await fetchAttempts();
  return { question, attempts };
}

If the operations are independent, call both functions before waiting for their combined result:

async function loadConcurrently() {
  const questionPromise = fetchQuestion();
  const attemptsPromise = fetchAttempts();

  const [question, attempts] = await Promise.all([
    questionPromise,
    attemptsPromise,
  ]);

  return { question, attempts };
}

The shorter form has the same call order:

async function loadConcurrently() {
  const [question, attempts] = await Promise.all([
    fetchQuestion(),
    fetchAttempts(),
  ]);

  return { question, attempts };
}

This arrangement can reduce total waiting time when independent operations can overlap. It does not promise a universal improvement. Browsers, networks, servers, and resource pools impose their own constraints.

Same work, different waiting timeSequential awaitsquestionattemptswaitingwaitingConcurrent callsquestionattemptswaitingwaitingoverlapping time
Independent operations can overlap instead of waiting in a line.

Dependencies change the correct structure. If fetchAttempts needs an identifier returned by fetchQuestion, awaiting the first result is required:

async function loadDependentData() {
  const question = await fetchQuestion();
  const attempts = await fetchAttempts(question.id);
  return { question, attempts };
}

Another common mistake is passing function references:

Reference stays inertfetchQuestionsame functionno requestCall starts workfetchQuestion() calledrequest startspromise returned
A function value is not the same thing as a function call.
const tasks = [fetchQuestion, fetchAttempts];
const results = await Promise.all(tasks);

This resolves with the function objects because Promise.all does not invoke them. Call each function first:

const tasks = [fetchQuestion, fetchAttempts];
const results = await Promise.all(tasks.map((task) => task()));

Array mapping is useful when each item creates one independent operation:

const attempts = await Promise.all(
  candidateIds.map((candidateId) => fetchAttempts(candidateId))
);

For interview practice that moves beyond one aggregate, an async task queue provides a useful follow-up problem.

Errors, Cancellation, and Concurrency Limits

Promise.all is fail-fast. Here, fail-fast means the aggregate rejects after observing an input rejection instead of waiting to produce a complete array of successful values.

Use try/catch around await when the surrounding function should handle the failure:

async function loadDashboard() {
  try {
    const [question, attempts] = await Promise.all([
      fetchQuestion(),
      fetchAttempts(),
    ]);

    return { question, attempts };
  } catch (error) {
    console.error('Dashboard loading failed:', error);
    throw error;
  }
}

The rejection reason comes from the first rejection observed by the aggregate. That is not necessarily the first item in input order.

Fail-fast does not mean cancel-fast. Rejecting the aggregate does not itself stop work that has already started. Another request, timer, or database operation can continue after the combined promise has rejected.

One rejection, two different effectsfast inputaggregateslow input×rejection observedaggregate rejectsstill running
The aggregate rejects, but unfinished work keeps going.

Some APIs provide a separate cancellation mechanism. For browser fetch, an AbortController can connect multiple requests to one signal:

async function loadWithAbort() {
  const controller = new AbortController();

  try {
    return await Promise.all([
      fetch('/api/question', { signal: controller.signal }),
      fetch('/api/attempts', { signal: controller.signal }),
    ]);
  } catch (error) {
    controller.abort();
    throw error;
  }
}

Calling abort() can abort connected fetch requests, response-body consumption, and streams. Cancellation remains an explicit decision made through the operation's API. Promise.all does not add cancellation to an operation that lacks it.

Promise.all is also not a concurrency limiter. Mapping a large collection creates every operation before the combinator waits:

Promise.all burstall started123456Pool: two active1234worker 1worker 2waiting in queue
Coordination gathers work; a pool controls when work starts.
const results = await Promise.all(
  candidateIds.map((candidateId) => fetchAttempts(candidateId))
);

If only a bounded number should be active, use batching or a worker-pool pattern. A Promise Pool exercise isolates that requirement. The pool decides when to call each producing function. Promise.all can still coordinate one selected batch, but it does not choose the batch size.

Promise.all vs. Other Promise Combinators

The correct combinator depends on which outcome the caller needs.

CombinatorSuccessful resultRejection behaviorEmpty iterable
Promise.allAll fulfillment values in input orderRejects after observing an input rejectionFulfills with []
Promise.allSettledEvery outcome object in input orderDoes not reject because an input rejectsFulfills with []
Promise.anyFirst fulfillment valueRejects with AggregateError when every input rejectsRejects with AggregateError
Promise.raceState and value or reason of the first settlementRejects if the first settlement is a rejectionRemains pending

Use Promise.all when every operation must succeed before the combined result is useful. Loading a question and its required test cases fits this rule.

Use Promise.allSettled when every outcome matters. A results screen that reports which optional resources loaded and which failed may need all status objects.

Use Promise.any when one successful result is enough. Rejections are tolerated until every input has rejected.

Use Promise.race when the first settlement decides the outcome. That settlement can be either fulfillment or rejection.

Promise.all×needs every ✓allSettled×keeps ✓ and ×Promise.any×first ✓ winsPromise.race×first settled wins
Choose the combinator by the outcome your caller needs.

These methods do not cancel losing or unfinished operations. If cancellation is required, it must be designed separately.

Implement Promise.all in an Interview

An interview implementation should begin with a behavioral contract:

  • Accept a synchronous iterable.
  • Preserve input order.
  • Normalize each input with Promise.resolve.
  • Fulfill after every input fulfills.
  • Fulfill an empty input with [].
  • Reject with an observed rejection reason.

Before reading the reference solution, implement the contract above in this starter function:

function interviewPromiseAll(iterable) {
  // Implement the contract above.
}

Run your implementation against the test matrix in the reference section when you are ready to compare it.

Reference solution and runnable test matrix

The implementation below is the canonical version for this exercise. Its test matrix is included so the code runs as one self-contained program.

function interviewPromiseAll(iterable) {
  return new Promise((resolve, reject) => {
    if (
      iterable == null ||
      typeof iterable[Symbol.iterator] !== 'function'
    ) {
      throw new TypeError('Expected a synchronous iterable');
    }

    const inputs = Array.from(iterable);
    const results = new Array(inputs.length);

    if (inputs.length === 0) {
      resolve([]);
      return;
    }

    let remaining = inputs.length;

    inputs.forEach((input, index) => {
      Promise.resolve(input).then(
        (value) => {
          results[index] = value;
          remaining -= 1;

          if (remaining === 0) {
            resolve(results);
          }
        },
        reject
      );
    });
  });
}

function delay(value, milliseconds, shouldReject = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (shouldReject) {
        reject(new Error(value));
      } else {
        resolve(value);
      }
    }, milliseconds);
  });
}

async function runTests() {
  const ordered = await interviewPromiseAll([
    delay('first', 30),
    delay('second', 5),
  ]);
  console.log(`ordering: ${JSON.stringify(ordered)}`);

  const thenable = {
    then(resolve) {
      resolve('thenable');
    },
  };

  const mixed = await interviewPromiseAll([
    3,
    Promise.resolve(4),
    thenable,
  ]);
  console.log(`mixed: ${JSON.stringify(mixed)}`);

  const empty = await interviewPromiseAll([]);
  console.log(`empty: ${JSON.stringify(empty)}`);

  let slowSettled = false;
  const slow = delay('slow success', 30).then((value) => {
    slowSettled = true;
    return value;
  });

  try {
    await interviewPromiseAll([
      slow,
      delay('early rejection', 5, true),
    ]);
  } catch (error) {
    console.log(`rejection: ${error.message}`);
    console.log(`slow pending at rejection: ${!slowSettled}`);
  }

  await slow;
  console.log(`slow continued: ${slowSettled}`);
}

runTests();
ordering: ["first","second"]
mixed: [3,4,"thenable"]
empty: []
rejection: early rejection
slow pending at rejection: true
slow continued: true

The index captured by each callback is the ordering invariant. The remaining counter tracks inputs whose fulfillment has not yet been recorded. Each fulfillment writes exactly one position and reduces the counter once.

One callback, one fixed slotinput 0pendinginput 1value Binput 2value Cslot 0emptyslot 1Bslot 2Cremaining = 1
Fixed slots preserve order; the counter detects completion.

Promise.resolve(input) is necessary. Without it, plain values have no .then method, and thenables would not participate through normal promise assimilation.

The rejection handler forwards the observed reason to the outer promise. Later settlements cannot change the outer promise after it rejects.

This remains an interview-scoped implementation, not a replacement for the native method. Native Promise.all has generic constructor behavior, detailed iterator handling and closing rules, thenable safeguards, and semantics for promise subclass constructors. The explicit iterator check limits this exercise to synchronous iterables before Array.from collects their values, but the code does not recreate the full specification machinery.

Edge Cases and Follow-Up Questions

The runnable matrix above covers the cases most likely to expose a broken implementation.

The ordering test proves that fulfillment values use input indexes. The mixed-input test covers a plain value, an existing promise, and a thenable. The empty-input test checks that the fulfillment value is []. The rejection test verifies that the aggregate rejects while the slow input is still pending, then shows that the slow operation continues.

Common broken implementations have recognizable causes:

  • Pushing values into the result array records completion order.
  • Calling .then directly on each input fails for plain values.
  • Forgetting the empty case leaves the returned promise pending.
  • Passing async function references returns those functions instead of calling them.
  • Awaiting each operation before building the array removes the intended overlap.
  • Treating aggregate rejection as cancellation misstates what happened to unfinished work.

For n inputs, this interview implementation uses an array of n results and attaches handling to each input. Converting the iterable and visiting its values takes linear work. The stored result array uses linear space.

A concurrency-limit follow-up changes the problem. Instead of creating every promise immediately, a bounded worker pool keeps a fixed set of workers active. Each worker takes the next producing function only after its current operation settles. The final results can still be stored by original index.

A concise spoken explanation can follow four points:

  1. State the contract: all fulfillments are required, results preserve input order, and an observed rejection rejects the aggregate.
  2. Name the invariants: one result slot per input and one remaining counter.
  3. Explain normalization: Promise.resolve handles promises, thenables, and plain values.
  4. State the production caveats: the combinator does not call promise-producing functions, cancel unfinished work, or limit concurrency, though assimilating a lazy thenable may start its work.

For more timed exercises and worked solutions, UIReady Premium Lifetime can extend this implementation into a full interview practice session.

Frequently asked questions

What does Promise.all do in JavaScript?
Promise.all accepts an iterable and returns one promise. That promise fulfills with an input-ordered array when every input fulfills, or rejects when the aggregate observes an input rejection.
Does Promise.all run promises in parallel?
Promise.all does not call promise-producing functions. Native promises normally represent work started when their producers were called, though normalizing a thenable can access and invoke its `then`, which may start lazy work. Independent operations can make overlapping progress, but that does not guarantee simultaneous execution.
Does Promise.all preserve result order?
Yes. The fulfillment array follows input order even when later inputs finish first.
Does Promise.all cancel unfinished work after a rejection?
No. The aggregate rejects after observing an input rejection, but other operations can continue unless their APIs provide a cancellation mechanism and the program uses it.
When should Promise.allSettled be used instead of Promise.all?
Use Promise.allSettled when every outcome matters, including failures. It fulfills after all inputs settle and returns an input-ordered array of status objects.