Problem and Applicable Context
Implement promiseAll(iterable) without calling the native Promise.all inside your solution. The input is a finite synchronous iterable containing promises, thenables, or plain values. The output is a new Promise.
When every input fulfills, the result must be an array in iteration order, regardless of completion order. When any input rejects, the outer Promise must reject with the reason from the first rejection to occur. An empty input must produce []. If reading from the iterator itself throws, the outer Promise must reject as well.
This is suitable for a live coding round for a frontend or JavaScript role. The solution may use the native Promise to assimilate thenables and schedule asynchronous reactions. It does not need to reproduce every specification detail involving subclass constructors, internal slots, and iterator closing. State that boundary before coding: “core behavior” and “fully specification-compatible implementation” are different assignments.
What the Interviewer Is Evaluating
A strong answer lists the semantics before writing the loop. Five useful signals are accepting an iterable instead of only an array, normalizing plain values and thenables through Promise.resolve, reserving an index to preserve order, handling empty input separately, and routing both iteration errors and promise rejections to the outer rejection.
A common answer calls map on an array and pushes each completed value. It may pass one example in which everything fulfills in order, while failing for a Set, a generator, empty input, or out-of-order completion. A stronger answer starts with invariants: results[i] always belongs to the value at iteration index i; pending is the number of inputs that have not fulfilled; fulfillment is allowed only when pending reaches zero.
The interviewer will also notice whether “fail fast” is confused with “cancel the rest.” Once the outer Promise rejects, later attempts cannot change its state, but work that has already started continues. Cancellation requires an additional protocol supported by the input operations; an aggregate Promise cannot invent that capability.
Questions to Clarify Before Answering
- Is the input always an array, or any synchronous iterable? Array-only support allows an index loop. Supporting the stated iterable contract calls for
for...ofand handling errors thrown while requesting the next value. - Are plain values and thenables valid inputs? If so, every item must pass through
Promise.resolve. Callingitem.thendirectly fails on numbers, strings, and ordinary objects. - Is this a specification-level polyfill or an interview implementation of core behavior? A specification-level version also deals with the
thisconstructor, Promise subclasses, internal guards against repeated calls, and exact iterator-closing rules. This solution returns a native Promise and does not claim full conformance. - Must remaining operations be canceled after a failure? If so, the prompt must define an AbortSignal or task cancellation interface. The current contract only rejects the outer Promise early; other operations keep running.
- Is the input finite?
Promise.allconsumes its input synchronously. Traversal of an infinite iterator never finishes. This solution assumes finite input so that space can be described in terms ofn.
These answers change the loop, error handling, and API contract, so they are worth confirming before implementation.
30-Second Answer Framework
“I will return a new Promise and synchronously traverse the finite iterable. For each item, I reserve its iteration index, increment a pending count, and use Promise.resolve to normalize a plain value, thenable, or Promise. A fulfillment handler writes to the reserved index and resolves the result array when the count reaches zero. The rejection handler rejects the outer Promise immediately. I will wrap iteration in try...catch so an iterator error also rejects. Empty input has no fulfillment handler, so I will resolve [] explicitly after traversal. This preserves input order and fails fast, but it does not cancel operations that have already started.”
Step-by-Step Deep Dive
Start with a tempting baseline that is not equivalent: await each item in sequence and collect the results. It preserves order, but serializes waits that could otherwise overlap. Until the previous item settles, the next reaction is not even registered. The requested operation aggregates inputs that have already been obtained; it is not a serial task queue.
The recommended implementation needs one result array, two counters, and one traversal:
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const results = [];
let pending = 0;
let index = 0;
try {
for (const item of iterable) {
const currentIndex = index;
index += 1;
pending += 1;
Promise.resolve(item).then(
(value) => {
results[currentIndex] = value;
pending -= 1;
if (pending === 0) {
resolve(results);
}
},
reject,
);
}
} catch (error) {
reject(error);
return;
}
if (index === 0) {
resolve([]);
}
});
}currentIndex is fixed during that iteration. Suppose three inputs settle after 30, 10, and 20 milliseconds. Their fulfillment handlers run in the order 1, 2, 0, but they still write to positions 1, 2, 0, so the final array is returned in order 0, 1, 2. Replacing the indexed assignment with results.push(value) would incorrectly return completion order.
Promise.resolve(item) covers two boundaries at once. A plain value becomes an already-fulfilled Promise. A thenable has its then method assimilated; even if a broken thenable invokes its callbacks more than once, the native Promise's one-way state prevents repeated settlement from reaching this handler. An item instanceof Promise test would miss both Promises from another realm and valid thenables.
Empty input needs an explicit branch. The counter starts at zero, and no fulfillment handler exists to call resolve. Checking index === 0 after traversal fulfills the returned Promise with an empty array. A .then registered by the caller still runs asynchronously under normal Promise rules.
The try...catch handles synchronous iteration failures. For example, a generator may throw on its second next() call after a handler has already been attached to the first value. The catch rejects the outer Promise; if the earlier value later fulfills, its handler cannot change the rejected state. Rejections from individual items are routed to the same reject. If several inputs reject, whichever rejection handler runs first determines the outer reason.
Traversal and settlement perform O(n) total work. The result array and per-item fulfillment reactions require O(n) space. Handling one fulfillment adds O(1) work for an indexed write and a decrement. If the product needs every fulfillment and rejection, use an all-settled contract. If it needs a concurrency limit, accept task factories and add a scheduler; passing this function a collection of Promises that have already started cannot retroactively limit concurrency.
Validation should extend beyond the happy path. Cover an empty iterable; a Set; a mix of plain values, Promises, and thenables; completion in reverse input order; the earliest rejection; and an iterator that throws partway through. Each case tests a specific invariant, which is more convincing than comparing one sample array.
High-Quality Sample Answer
“I will scope this to a finite synchronous iterable and a native Promise result, without claiming full Promise-subclass conformance. Three semantics must hold: results follow iteration order, the outer Promise rejects as soon as an input rejects, and empty input fulfills with an empty array.
During traversal, I assign every item a stable index and increment pending. Each item goes through Promise.resolve, so numbers, existing Promises, and thenables share one path. On fulfillment, I write to the stable position and decrement the count; the final fulfillment resolves the complete array. The rejection handler is the outer reject. I put for...of inside try...catch because obtaining the next iterable value may throw synchronously. If traversal finds no elements, I resolve with [] directly.
Traversal and settlement take O(n) work and O(n) space. Fail-fast behavior changes only the aggregate result; it does not stop the other asynchronous operations. If cancellation is required, I would add an AbortSignal to the input task contract. If every error must be collected, I would use all-settled semantics instead of changing this function's rejection rule.”
Common Mistakes
- Collecting fulfilled values with
push→ array order follows completion speed, so a slow first item can appear last → capturecurrentIndexduring traversal and assignresults[currentIndex]. - Calling only
item.then(...)→ plain values have nothen, and non-native thenables are not safely assimilated → normalize every item withPromise.resolve(item). - Starting the pending count from an input length while claiming iterable support → Sets and generators have no dependable
length, silently narrowing the contract → incrementpendingas values are traversed. - Forgetting empty input → no handler can trigger fulfillment, leaving the returned Promise pending forever → resolve an empty array when
index === 0after traversal. - Using
forEach(async ...)and then awaiting it →forEachdoes not await asynchronous callbacks, so control flow and error propagation are wrong → register.thenhandlers directly and aggregate with the counter. - Handling only input rejections → a synchronous throw from an iterable's
next()escapes the reasoning → wrap the entire traversal intry...catchand reject the outer Promise. - Claiming fail-fast behavior cancels requests → an irreversible Promise state does not stop the underlying operation → state that cancellation requires AbortSignal or another task-level cancellation protocol.
Follow-Up Questions and Responses
Follow-up 1: How can you prove that result ordering is correct?
Use this invariant: the value at iteration index i owns only index i, and its fulfillment handler writes only to results[i]. Handler order changes when a write occurs, never where it occurs. After all n inputs fulfill, positions 0 through n - 1 therefore contain the corresponding inputs' values. Test the claim with three Promises whose delays are the reverse of their input order instead of relying on equal delays.
Follow-up 2: How would you cancel remaining network requests after a rejection?
The current function cannot, because a Promise does not expose the underlying operation's cancellation entry point. Change the input to task factories that receive an AbortSignal, and create one shared AbortController. When a task fails, call controller.abort() before rejecting the outer Promise. Some tasks may already be complete, ignore the signal, or fail during cancellation, so request cancellation is an extended contract rather than a hidden part of standard Promise.all semantics.
Follow-up 3: What if at most three tasks may run at once?
Change the input from already-started Promises to functions that have not started. Track the next task index, the number currently running, and the result array. Start another task whenever one settles, while keeping the running count at or below 3. If the API still accepts a Promise array, its operations usually start while that array is constructed, so the scheduler is already too late. The key issue is start time, not renaming the completion counter.
Follow-up 4: What remains different from a specification-level Promise.all?
This implementation always uses the native Promise. It does not obtain a constructor from this, or reproduce Promise subclass behavior, internal slots, the specification's iterator-closing steps, and every abrupt-completion detail. In an interview, call it an implementation of core behavior. A publishable polyfill requires a step-by-step mapping to the ECMAScript algorithm plus compatibility tests; common examples alone do not establish conformance.