Free lesson · JavaScript Basic to Advance
Interview Lens: Promise and async/await Patterns
The Question
The other half of async interview questions — the implementation and pattern questions:
"Implement Promise.all from scratch."
"Write a function that retries a Promise N times on failure."
"Write a debounce function." (Yes, again — it comes up in both function and async sections.)
"What's the difference between Promise.all, Promise.race, Promise.allSettled, and Promise.any?"
"Explain how async/await works under the hood."
Where Topic 9.11 covered the event loop conceptual questions, this Topic covers the implementation patterns. These questions cluster at mid-to-senior interviews. They test whether you can build async primitives, not just use them.
Emeka covered most of the technical material in Module 9. I'll handle the implementation challenges here. Arun joins for the interview-strategy framing — these are the questions where reasoning out loud matters as much as the code.
Why They Ask It
Building a Promise primitive — Promise.all, retry logic, debounce, throttle — tests three things at once. It tests whether you understand the Promise model deeply enough to recreate it. It tests whether you can manage state cleanly across async operations. And it tests whether you can write clean code under interview pressure.
Three signals interviewers listen for:
Signal 1: Do you know what the function should do, precisely? Many candidates implement Promise.all with a vague sense of "wait for all of them." The precise spec — preserves order in the result array, fails fast on first rejection, returns an empty array for empty input — matters. Getting the edge cases right separates "remembered the high-level idea" from "actually understands the API."
Signal 2: Can you write clean code under pressure? A working implementation in five lines beats a working one in twenty. Senior candidates show fluency — clean variable names, no extra state, minimal mutation.
Signal 3: Do you handle errors correctly? Async error handling is where most candidates trip up. Forgetting to reject the outer Promise, double-rejecting, swallowing errors silently — these are common mistakes that signal weak async understanding.
I (Arun) bombed the Promise.all implementation question in my eighth or ninth interview. I got the basic structure right but forgot to handle the empty-array case — I returned a Promise that never resolved. The interviewer asked "what happens with an empty input?" I tried to think through it, realized my bug, fixed it. Got the offer anyway, because I caught and fixed the bug visibly. The lesson: handle edge cases first. Empty array. Single element. All success. All failure. Mixed. Going through these explicitly under pressure signals you've thought carefully.
The Wrong Answers
Warning: The Promise implementation mistakes interviewers see most often.
Implementing
Promise.allby chaining.thensequentially. This makes the operations run one after another, defeating the whole purpose.Promise.allshould run them in parallel — call all the Promises immediately, then wait for the results.Forgetting to preserve order. When Promises resolve in different orders than they were given, the result array should still match the input order. Candidates who push results as they resolve (instead of indexing into a fixed array) produce out-of-order results.
Not handling the empty array.
Promise.all([])should resolve immediately with an empty array. Implementations that wait for "all" Promises to resolve without checking for zero will hang forever.Double-rejecting on multiple failures. If two Promises reject, the outer Promise should reject exactly once — with the first rejection. Implementations that call
rejectmultiple times produce undefined behavior depending on the runtime.
Interactive animation: The loop that hides a 10x - awaiting each fetch in turn against starting all ten with map and Promise.all.
The Right Answer
Three layers depending on what's being asked.
Implementing Promise.all
The canonical implementation:
function myAll(promises) {
return new Promise((resolve, reject) => {
if (promises.length === 0) {
resolve([]);
return;
}
const results = new Array(promises.length);
let completedCount = 0;
promises.forEach((promise, index) => {
// Promise.resolve handles non-Promise values gracefully
Promise.resolve(promise).then(
value => {
results[index] = value;
completedCount++;
if (completedCount === promises.length) {
resolve(results);
}
},
error => {
reject(error); // fail-fast on first rejection
}
);
});
});
}
myAll([
Promise.resolve("a"),
new Promise(r => setTimeout(() => r("b"), 50)),
Promise.resolve("c")
]).then(results => console.log("Results:", results));
myAll([]).then(r => console.log("Empty:", r));
Walk through it out loud:
"Promise.all needs to handle three cases: all succeed, any fail, empty input. I'll start with the empty case — if the array is empty, resolve immediately with an empty array.
For the general case, I need to track results in the original order. I create a results array of the right size upfront. Each Promise's then callback writes to the correct index. I also need a counter to know when all Promises have completed.
For success, I write the value to the index and increment the counter. When the counter equals the input length, I resolve the outer Promise.
For failure, I reject the outer Promise immediately. Promises can only settle once, so subsequent rejections are silently ignored — but I don't need to do anything special about it.
The Promise.resolve wrapper handles non-Promise values gracefully, in case someone passes a mix of Promises and regular values. Promise.resolve on an existing Promise returns it unchanged, so there's no overhead."
That's a clean implementation. The interviewer might extend with "implement allSettled" or "implement race."
Implementing a Retry Pattern
Another common interview question:
async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 100) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxAttempts) break;
const delay = baseDelay * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error(`Failed after ${maxAttempts} attempts: ${lastError.message}`);
}
// Test
let calls = 0;
async function flakyOperation() {
calls++;
if (calls < 3) throw new Error(`Attempt ${calls} failed`);
return "succeeded on attempt 3";
}
retryWithBackoff(flakyOperation, 5, 50)
.then(r => console.log("Got:", r));
Walk through:
"Retry needs a loop with a try/catch inside. Each iteration tries the function. On success, return the result. On failure, store the error and wait before retrying — unless we've hit the max attempts.
The backoff doubles the delay each time: 100ms, 200ms, 400ms, 800ms. This is exponential backoff, used in real-world retry logic to avoid hammering a failing service.
If all attempts fail, throw a final error with context. The caller can catch this if they want, or let it propagate."
The interviewer might extend: "Add jitter to the delay" or "Make it cancellable." Both are extensions of the same pattern.
Implementing Debounce
We covered debounce briefly in Topic 5.8. The async-aware version:
function debounce(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
const log = (msg) => console.log("Executing:", msg);
const debouncedLog = debounce(log, 100);
debouncedLog("first");
debouncedLog("second");
debouncedLog("third");
// Only "third" runs, after 100ms — earlier calls were cancelled
Walk through:
"Debounce wraps a function in a delay. Every time the wrapped function is called, it cancels any pending timer and starts a new one. The original function only runs when there's a full delay with no new calls.
The state — timeoutId — lives in the closure between the outer function and the returned function. Each call to debounce produces a new closure with its own timeoutId.
For a more advanced version, you'd add a way to cancel pending calls explicitly, or to fire immediately on the first call. These extensions test whether you can extend the basic pattern."
The Pattern Implementation Cheat Sheet
For each common pattern, the key state and the key logic:
Promise.all
- State: results array, completed counter
- Logic: each Promise writes its result by index, resolve when counter == length, reject immediately on any failure
Promise.race
- State: none
- Logic: each Promise calls both resolve and reject; the first one wins (Promises ignore subsequent settles)
Promise.allSettled
- State: results array, completed counter
- Logic: each Promise writes
{status, value/reason}by index, resolve when counter == length (never reject)
Promise.any
- State: errors array, rejected counter
- Logic: first success resolves; track all errors; reject with AggregateError if all fail
Debounce
- State: pending timer
- Logic: cancel previous timer, start a new one on each call
Throttle
- State: last execution time
- Logic: only call if enough time has passed since last call
Memoize
- State: cache (Map keyed by arguments)
- Logic: check cache, return cached or compute-and-cache
Retry
- State: attempt counter
- Logic: loop with try/catch, backoff between attempts
Memorize these patterns. When asked to implement one in an interview, the structure is what you reach for.
The Follow-Ups
Interview: What the interviewer asks next.
"How would you handle this if the input contained non-Promise values?" Use
Promise.resolve(value)to wrap non-Promises. It's a no-op for actual Promises and wraps everything else."What if
fnis itself an async function? Does that change anything?" No — async functions return Promises, whichawaithandles uniformly. The retry logic works the same."Can you add cancellation?" A common extension. Implement with
AbortController(modern) or a cancel flag (older pattern)."How does this compare to the native
Promise.all?" Native implementations are slightly more optimized and handle some edge cases more carefully (iterators instead of just arrays, for example). Functionally equivalent for normal use."What if a Promise resolves with another Promise?"
Promise.allflattens — it waits for the inner Promise too. This is the "Promise unwrapping" behavior that makes chains work cleanly. Most candidates don't think about this until asked.
Interactive animation: The rapid-fire gotcha round - four async one-liners, what each actually does, and the fix.
The Founder Moment
Emeka here. The interview that taught me the most about async patterns was at Booking.com, before Netflix. The interviewer asked me to implement a function that runs N async operations with a maximum concurrency limit — for example, "run 100 API calls but only 5 in parallel at any time."
This isn't
Promise.all(no concurrency limit) and isn't sequential (one at a time). It's a queue with parallelism control. At the time, I'd never thought about it. I gave a naive answer that didn't really work.The interviewer walked me through it. The pattern: maintain a count of currently running operations. When one finishes, start the next pending one. The implementation involves a small async loop and a counter, with each completion triggering the next start.
I didn't get that job. But the pattern stuck with me. Three years later, at Netflix, I needed exactly that pattern for our event processing system — fetching enrichment data for events at controlled concurrency to avoid overwhelming a downstream service. I implemented it in maybe twenty minutes, because I'd thought about it carefully in that interview years before.
What I took from that: the questions that hurt the most in interviews are the ones whose patterns you'll need in production. Failed interviews are learning material if you go home and actually master what you missed. The interview is a signal of what to learn next, not a verdict on whether you're qualified.
Master the async patterns. They'll show up in interviews and in production. Spend the time.