Index

Callbacks, promises and async await

A promise is one object with three states and one guarantee: your callback will be called exactly once, later, by the engine - not by whoever you handed it to. That guarantee is the actual answer to 'what problem do promises solve', and it is not callback hell. Callback hell is ugly; inversion of control is a bug. Most of the questions here are checking whether you know the difference, and whether you have written the four bugs at the bottom of the page.

Three states, and the words for them

A promise is pending, fulfilled, or rejected. Settled means fulfilled or rejected - it is not a fourth state, it is a word for 'not pending any more'. Once settled it never changes: the value is frozen and every handler attached afterwards, even a year later, gets that same value immediately (well, on the next microtask).

Resolved is the confusing one and the one people get wrong out loud. A promise is resolved when it has been LOCKED to a fate - which may be another promise that is still pending. So a promise can be resolved and still pending. Fulfilled is the state where it actually has a value. If you say 'resolved' when you mean 'fulfilled' nobody will stop you, but knowing the difference is a cheap point.

The real problem with callbacks

Nesting is the symptom everyone names. The disease is inversion of control: when you pass a callback into someone else's function, you hand them the right to decide whether it runs, how many times, with what arguments, and whether it runs synchronously or later. A buggy or hostile library can call it twice, call it never, call it with the wrong number of arguments, or call it synchronously in one branch and asynchronously in another - and there is nothing at your call site that can defend against any of that.

A promise takes that control back. The library returns an object; YOU attach the handler; the engine calls it. Exactly once, always asynchronously, with one value. That is the whole pitch, and it is why promises are not merely nicer syntax.

Questions

Answer each one yourself before you open it.

  1. 01What problem do promises actually solve?

    Inversion of control. With a callback you hand your function to someone else and trust them to call it once, with the right arguments, at the right time. With a promise they hand you an object and you attach the handler yourself - the engine calls it, exactly once, always asynchronously.

    The nesting answer is the one everyone gives and it is the weaker half. Promises do flatten a pyramid, but async/await is what really fixed the shape. What promises fixed is the trust problem: a callback API can call you twice, or never, or synchronously on the cached path and asynchronously otherwise, and your code has to survive all of it.

    Follow-up Give an example of that going wrong.

    A cache wrapper that calls your callback synchronously on a hit and asynchronously on a miss. Now a variable you assign on the next line is undefined half the time. With a promise it is asynchronous on both paths, always, by specification.

  2. 02What is an error-first callback?

    Node's convention: the callback's first argument is an error or null, and the result comes after it. fn(err, data), and you check if (err) return handleIt(err) as the first line.

    It exists because a callback has no throw channel - an exception thrown inside an asynchronous callback cannot be caught by the code that started the operation, because that stack is long gone. So the error is passed as data instead. Promises replaced this with a real rejection channel, which is why try/catch works again with await.

  3. 03What are the three states of a promise?

    Pending, fulfilled, rejected. Settled means fulfilled or rejected. Once it settles, the state and value never change.

    Follow-up What is the difference between resolved and fulfilled?

    Resolved means the promise's fate has been locked in - possibly to another promise that is still pending, so a resolved promise can still be pending. Fulfilled means it actually has a value now.

  4. 04What does .then return?

    Always a new promise. That is what makes chaining work, and it is why the return value of your callback matters - it becomes the value of that new promise.

    Three cases, and knowing all three is the answer. Return a plain value and the next promise fulfils with it. Return a promise and the next one adopts it, waiting for it to settle. Throw and the next one rejects. A callback that returns nothing gives the next link undefined, which is the source of a lot of 'why is it undefined' confusion in a chain.

  5. 05What is the difference between .then(f, g) and .then(f).catch(g)?

    .then(f, g) will not catch an error thrown inside f, because g is a handler on the PREVIOUS promise. .then(f).catch(g) will, because catch is attached to the promise then returned.

    This is a genuine bug in real code, not a trivia question. If your success handler throws - a bad property access, a failed parse of a response - the two-argument form silently loses it and you get an unhandled rejection. Default to .then().catch() and only use the two-argument form when you deliberately want to distinguish 'the operation failed' from 'my handling of it failed'.

  6. 06What does .finally do?

    Runs on both paths and passes the value or rejection straight through - its return value is ignored. For cleanup: hide the spinner, close the connection, re-enable the button.

    Two things about pass-through. It does not receive the value, so you cannot use it to inspect the result. And it does not swallow a rejection: a chain that ends in .finally and no .catch still produces an unhandled rejection. The one way finally does affect the chain is by throwing itself, which replaces whatever was in flight.

  7. 07Does an async function always return a promise?

    Always. Return a value and it comes back wrapped as a fulfilled promise; return a promise and the result adopts it; throw and you get a rejected promise instead of a synchronous exception.

    The consequence people trip on: you cannot get a synchronous throw out of an async function, even for argument validation on the first line. If a caller does not await and does not .catch, that validation error becomes an unhandled rejection instead of a crash at the call site.

  8. 08What does await do to a non-promise?

    Wraps it and resumes on the next microtask. await 42 gives you 42, one tick later - it does not resume immediately.

    It also unwraps thenables, not just native promises: anything with a .then method gets adopted. That is why await works on jQuery deferreds and on the promise-like objects some libraries return, and also why an accidental object with a then property behaves very strangely when returned from an async function.

  9. 09How do you catch an error from an awaited call?

    A plain try/catch around the await. A rejected promise resumes the function by throwing at the await point, so ordinary synchronous error handling works.

    Follow-up And if you forget the await?

    The try/catch becomes decorative. The call returns a pending promise, the try block exits successfully, and the rejection lands later with nobody listening - an unhandled rejection that your handler was theoretically written to catch.

  10. 10Two independent API calls each take a second. Your function takes two seconds. What is wrong?

    You awaited them one after the other, so the second request does not even start until the first finishes. Start both, then await both: const [a, b] = await Promise.all([getA(), getB()]).

    The rule is 'await as late as possible'. getA() starts the work; await only decides when you block on it. Even without Promise.all, calling both first and then awaiting each variable gives you one second - though that version leaks an unhandled rejection if the first one throws before you reach the second await.

    Follow-up When is the sequential version correct?

    When the second call needs the first one's result, or when you are deliberately rate-limiting yourself against an API that will throttle you. Sequential is not wrong, it is just usually unintentional.

  11. 11Promise.all vs allSettled vs race vs any - one line each.

    all gives you every value, and rejects the moment one fails. allSettled never rejects and gives you a status per entry. race settles with the FIRST one to settle, success or failure. any gives you the first SUCCESS, and only rejects if they all fail.

    Choose by what a failure means. If one failure makes the whole screen useless, all. If you are rendering six independent widgets, allSettled so five still appear. If you are calling three mirrors of the same service, any. race is almost always about timeouts rather than about picking a winner.

    Follow-up What does Promise.any reject with?

    An AggregateError, with an errors array holding all of the individual failures.

  12. 12Promise.all rejects on the first failure. Do the other requests get cancelled?

    No. They keep running to completion, their results are just discarded, and if one of them rejects later it is already handled by all so it does not warn.

    This is the trap in 'fail fast': the word describes when you find out, not what happens to the work. If you actually need to stop the others you need an AbortController and to pass its signal to every request yourself.

  13. 13What is the shape of allSettled's result?

    An array in input order of { status: 'fulfilled', value } or { status: 'rejected', reason }. Same length as the input, always, and it never rejects.

  14. 14How do you time out a fetch?

    AbortController: create one, pass controller.signal into fetch, and setTimeout(() => controller.abort(), ms). The fetch rejects with an AbortError. Clear the timer in a finally so a fast response does not leave it pending.

    Promise.race against a timer is the older answer and it is worse: it settles your promise but the request itself keeps running, holding a connection and eventually resolving into nothing. Abort actually cancels the work. Say race if asked how it was done before, then say why abort replaced it.

    Follow-up Does aborting work for anything other than fetch?

    Yes - the signal is a general cancellation primitive. addEventListener takes one, Node streams and setTimeout promises take one, and your own async functions can check signal.aborted or listen for its abort event.

  15. 15What is the promise constructor anti-pattern?

    Wrapping something that is already a promise in new Promise. If a function returns a promise, return it or await it - do not build a second promise around it.

    The wrapper loses errors. new Promise((res) => { doThing().then(res); }) has no rejection path at all, so a failure hangs forever instead of propagating. Even written correctly it is more code that does nothing. The ONLY legitimate use of the constructor is wrapping something that is not promise-based yet: a callback API, an event, a timer.

  16. 16Why doesn't forEach wait for an async callback?

    Because forEach ignores return values. Each callback returns a pending promise that nothing looks at, so the loop finishes immediately and all the work runs concurrently and unawaited.

    Two failure modes, not one. The obvious one is ordering: your code after the loop runs before any of the items. The nastier one is errors - a rejection inside one of those callbacks is unhandled, so it does not reach your try/catch and in Node it can take the process down.

    Follow-up What do you use instead?

    for (const item of items) { await work(item); } for sequential, or await Promise.all(items.map(work)) for concurrent. Pick deliberately: map + all fires every request at once, which is fine for ten items and a denial of service against your own API for ten thousand.

  17. 17What is an unhandled rejection and what happens on one?

    A promise that rejects with no rejection handler attached by the time the microtask queue finishes draining. The browser logs it and fires an unhandledrejection event; Node 15 and later prints it and exits with a non-zero code.

    The timing matters: attaching a .catch in a setTimeout is too late, even though the handler does eventually run. So the fix is always to attach the handler in the same turn - either await the thing inside a try/catch, or put a .catch on it at the point you create it.

  18. 18Where should the .catch go in a chain?

    At the end, so it covers every link above it. One .catch at the bottom catches a throw from any earlier .then, because each link passes the rejection down until something handles it.

    Put one in the middle and you have declared 'recover here and carry on' - the chain continues in the fulfilled state with whatever your catch returned. That is a legitimate pattern for a fallback value, and a bug when you meant 'stop'. If you want to stop, rethrow.

  19. 19Can you attach two .thens to the same promise?

    Yes. That branches - both handlers get the same value and neither sees the other's return. It is not a chain, it is a fork.

    This is how the same fetch feeds two consumers. It also explains a common confusion: p.then(a); p.then(b); runs a then b on the same value, while p.then(a).then(b) feeds a's return into b.

  20. 20What does Promise.resolve(p) do when p is already a promise?

    Returns the very same promise object, unchanged. It only wraps things that are not already native promises.

    Useful for normalising an API that might return a value or a promise: Promise.resolve(maybe).then(...) works either way at the cost of a tick. new Promise((r) => r(p)) is NOT the same - that creates a new promise that adopts p, which is two extra microtask ticks and a different object.

  21. 21How do you turn a callback API into a promise?

    Wrap it once in new Promise, resolving in the success path and rejecting in the error path. In Node, util.promisify does it for anything following the error-first convention.

    Follow-up What is the one thing to be careful about?

    That every path settles the promise, including the synchronous throw some callback APIs still do on bad arguments. A path that neither resolves nor rejects is a promise that hangs forever, and there is no timeout and no error - the await just never returns.

  22. 22Can you cancel a promise?

    No. A promise is a handle on a result, not a handle on the work, and it has no cancel method. You cancel the underlying operation - with an AbortController - and the promise rejects as a consequence.

    Worth being precise, because 'you can't cancel a promise' sounds like a dead end and interviewers are checking whether you know the way round it. The signal goes into the operation; the promise just reports what happened.

  23. 23How would you retry a failing request?

    A loop with a try/catch: attempt, and on failure wait and try again, up to N times, rethrowing the last error if they all fail. Back off exponentially and add jitter.

    Two things separate a real answer from a snippet. Only retry what is retryable - a 500 or a network error, never a 400 or a 401, and never a non-idempotent POST without an idempotency key. And add jitter, because a thousand clients retrying on the same doubling schedule reconverge and hit the recovering service in a synchronised wave.

  24. 24What is the difference between concurrency and parallelism here?

    Concurrency is what Promise.all gives you: several operations in flight at once on one thread, each waiting on something outside JavaScript. Parallelism means code executing simultaneously, which needs Workers.

    Ten fetches via Promise.all are genuinely concurrent - ten sockets open at once - and finish in the time of the slowest, not the sum. Ten CPU-bound functions in a Promise.all finish in exactly the sum, because they take turns on the one stack.

  25. 25How do you run 500 requests without firing 500 at once?

    A concurrency pool: keep N in flight, and start the next one as each finishes. Promise.all with a chunked loop is the crude version; a worker-pool helper that pulls from a shared index is the good one.

    Chunking in batches of ten is the answer most people give and it wastes time: the whole batch waits for its slowest member before any of the next ten start. Pulling from a shared queue keeps all ten slots busy the entire run.

  26. 26Does await in a loop always mean you have written a bug?

    No. It means serial, and serial is sometimes exactly right - a rate-limited API, a database migration that must apply in order, anything where step N needs step N-1.

    Worth saying explicitly, because 'never await in a loop' has become cargo cult. The real question is whether the iterations are independent. If they are, parallelise; if they are not, the loop is the correct code.

  27. 27What does for await...of do?

    Iterates an async iterable, awaiting each value in turn. For consuming a stream - chunks of a response body, paginated API results, lines from a file - where the next value does not exist yet when you ask for it.

  28. 28Is a promise executor synchronous?

    Yes. The function you pass to new Promise runs immediately, on the current stack, before the constructor returns. Only the .then callbacks are deferred.

    This is why the eager/lazy distinction matters: a promise starts its work at creation, not when you await it. Unlike a task or a future in some other languages, you cannot hold a promise and decide later whether to run it - if you need lazy, you need a function that returns a promise.

  29. 29What happens if you call resolve twice?

    Nothing. The first settle wins and the rest are ignored silently - no error, no warning.

    That silence is the guarantee: 'called exactly once' is enforced by the promise, not by the code that resolves it. Which is precisely the protection a raw callback does not give you.

  30. 30How do you convert a rejection into a value you can branch on?

    .catch(() => fallback) for a default, or await p.then((v) => [null, v], (e) => [e, null]) for a Go-style tuple. Promise.allSettled does the same thing for a list.

  31. 31Is await inside a try block enough error handling for a whole function?

    Only for what is inside the block. A rejection from a promise created before the try, or from a call you forgot to await, is outside it - the try block only catches what throws while it is on the stack.

  32. 32What is the difference between throwing inside an async function and returning Promise.reject?

    Practically nothing - both produce a rejected promise. throw gives you a stack trace from the throw site and reads better; return Promise.reject only if you are not in an async function.

  33. 33Your Promise.all result array - what order is it in?

    Input order, always, regardless of which finished first. Destructuring it positionally is safe.

  34. 34When would you NOT use async/await?

    When you actually want the branching shape a chain gives you - a fork feeding two consumers - or in top-level code in an environment without top-level await. And inside array callbacks like forEach, map for filtering, or a reduce where the accumulator is not awaited: those need restructuring, not an async keyword.

  35. 35How do you make a synchronous-looking sleep?

    You do not; you make an asynchronous one. const sleep = (ms) => new Promise((r) => setTimeout(r, ms)), then await sleep(500). There is no way to pause the thread without freezing everything, and a while (Date.now() < end) {} loop does exactly that.

Predict the output

The output is hidden. Predict it before you open it.

What a `.then` returns

Promise.resolve(1)
  .then((v) => v + 1)
  .then((v) => { console.log(v); })
  .then((v) => console.log(v));
Show the output
2
undefined

The first handler returns 2, so the next promise fulfils with 2. The second logs it but returns nothing, so the third link receives undefined. Forgetting the return inside a .then is the single most common promise bug and it always shows up as an undefined two links later.

A non-function handler

Promise.resolve('a')
  .then(null)
  .then('not a function')
  .then((v) => console.log(v));
Show the output
a

If a handler is not callable, the promise machinery passes the value straight through instead of throwing. That is why .then(null, handler) works as an error-only handler, and why a typo in a handler name fails silently rather than loudly.

Recovering in a catch

Promise.reject(new Error('boom'))
  .catch((e) => {
    console.log('caught', e.message);
    return 'recovered';
  })
  .then((v) => console.log('then', v))
  .catch(() => console.log('never'));
Show the output
caught boom
then recovered

A catch that returns normally puts the chain back into the fulfilled state, so the following .then runs with the returned value and the final .catch never fires. If you meant 'stop here', you have to rethrow.

throw, catch, finally, continue

Promise.resolve()
  .then(() => { throw new Error('x'); })
  .then(() => console.log('skipped'))
  .catch((e) => console.log('caught', e.message))
  .finally(() => console.log('finally'))
  .then(() => console.log('after'));
Show the output
caught x
finally
after

A rejection skips every .then success handler until it meets a catch, so 'skipped' never prints. After the catch handles it the chain is fulfilled again, so finally and the last .then both run.

finally passes through, and finally that throws

Promise.resolve('v')
  .finally(() => 'ignored')
  .then((v) => console.log('A', v));

Promise.resolve('v')
  .finally(() => { throw new Error('f'); })
  .catch((e) => console.log('B', e.message));
Show the output
B f
A v

finally's return value is discarded - the original value flows past it untouched, which is why it is safe for cleanup. Throwing is the exception: that replaces whatever was in flight, so a cleanup step that can fail destroys the result it was cleaning up after. The order is the bonus point: the pass-through path is specified as adopting a promise internally, which costs extra microtask ticks, so B actually prints first even though A was written first.

Promise.all fails fast

const ok = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms));
const bad = (ms, m) => new Promise((_, j) => setTimeout(() => j(new Error(m)), ms));

Promise.all([ok(30, 'a'), bad(10, 'boom'), ok(20, 'c')])
  .then((vs) => console.log('all', vs))
  .catch((e) => console.log('rejected', e.message));
Show the output
rejected boom

It rejects at 10ms with the first failure, and the two successful timers still run to completion at 20ms and 30ms - their values are simply thrown away. Failing fast is about when you are told, not about stopping the work.

allSettled never rejects

Promise.allSettled([
  Promise.resolve(1),
  Promise.reject(new Error('no')),
]).then((rs) => console.log(rs.map((r) => r.status).join(',')));
Show the output
fulfilled,rejected

Same length as the input, same order, one object per entry with a status and either value or reason. Note that no .catch is needed here and one would never fire - which is exactly why you reach for it when partial failure is acceptable.

race vs any

const slow = new Promise((r) => setTimeout(() => r('slow'), 50));
const failFast = new Promise((_, j) => setTimeout(() => j(new Error('fast fail')), 10));

Promise.race([slow, failFast]).then(
  (v) => console.log('race', v),
  (e) => console.log('race err', e.message),
);

Promise.any([slow, failFast]).then(
  (v) => console.log('any', v),
  (e) => console.log('any err', e.message),
);
Show the output
race err fast fail
any slow

race settles on the first to SETTLE, which at 10ms is the failure. any settles on the first to SUCCEED, so it ignores the failure and waits for the slow one at 50ms. That difference is the whole reason both exist.

The two-second bug and the fix

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function sequential() {
  const t = Date.now();
  await sleep(1000);
  await sleep(1000);
  console.log('sequential', Math.round((Date.now() - t) / 1000) + 's');
}

async function parallel() {
  const t = Date.now();
  await Promise.all([sleep(1000), sleep(1000)]);
  console.log('parallel', Math.round((Date.now() - t) / 1000) + 's');
}

sequential();
parallel();
Show the output
parallel 1s
sequential 2s

In sequential the second sleep is not even created until the first resolves. In parallel both are created immediately and the await comes after, so the clocks overlap. Notice that the parallel one also prints FIRST, a second earlier - this is the shape of the bug in every 'why is our dashboard slow' review.

forEach with an async callback

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function run() {
  [1, 2, 3].forEach(async (n) => {
    await sleep(10);
    console.log('item', n);
  });
  console.log('done');
}

run();
Show the output
done
item 1
item 2
item 3

forEach throws away each callback's returned promise, so it returns the instant the three callbacks hit their first await. 'done' prints before any item. Swap in for (const n of [1, 2, 3]) { await sleep(10); console.log('item', n); } and you get item 1, 2, 3, then done.

The forgotten await

function boom() {
  return Promise.reject(new Error('nope'));
}

async function run() {
  try {
    boom();
  } catch (e) {
    console.log('caught', e.message);
  }
  console.log('reached the end');
}

run();
Show the output
reached the end

The catch block never runs. boom() returns a pending promise and returns normally, so nothing throws inside the try - the rejection arrives later with no handler attached. In the browser you get an unhandledrejection in the console; in Node 15 and later the process prints the error and exits non-zero. One missing keyword turns a handled error into a crash.

Async functions and their return values

async function f() { return 1; }
async function g() { return Promise.resolve(2); }

console.log(f() instanceof Promise, g() instanceof Promise);
f().then((v) => console.log('f', v));
g().then((v) => console.log('g', v));
Show the output
true true
f 1
g 2

Both calls return a promise even though neither body has an await. f is fulfilled the moment it returns, while g has to adopt the promise it returned - two extra microtask ticks - which is why f 1 always prints before g 2 no matter how the calls are ordered.

Throwing in an async function

async function f() {
  throw new Error('bad');
}

f().catch((e) => console.log('caught', e.message));
console.log('after');
Show the output
after
caught bad

The throw does not propagate to the caller's stack - it becomes a rejected promise, so the line after the call still runs and the error surfaces one microtask later. This is why you cannot wrap an async call in a plain try/catch without awaiting it.

The executor runs synchronously

console.log('a');

const p = new Promise((resolve) => {
  console.log('b');
  resolve('c');
  console.log('d');
});

p.then((v) => console.log(v));
console.log('e');
Show the output
a
b
d
e
c

The function passed to new Promise runs immediately, on the current stack - hence b before e. resolve does not return from the executor either, so 'd' still prints. Only the .then callback is deferred to the microtask queue, which is why 'c' is last.

Settling twice

const p = new Promise((resolve, reject) => {
  resolve('first');
  reject(new Error('second'));
  resolve('third');
});

p.then(
  (v) => console.log('ok', v),
  (e) => console.log('err', e.message),
);
Show the output
ok first

The first settle wins and the rest are ignored with no error and no warning. That silence is the guarantee: a promise callback runs exactly once no matter how badly the producer behaves, which is precisely the protection a raw callback cannot offer.

Two handlers on the same rejected promise

const p = Promise.reject(new Error('x'));

p.catch((e) => console.log('A', e.message));
p.then(() => console.log('B')).catch((e) => console.log('C', e.message));
Show the output
A x
C x

This is a fork, not a chain. Both handlers are attached to the same rejected promise, so both see the same error; 'B' never runs because a rejection skips success handlers. Ordering is registration order - A was attached first.

await unwraps all the way

async function f() {
  const v = await Promise.resolve(Promise.resolve(7));
  console.log(v, typeof v);
}
f();
Show the output
7 number

Promise.resolve given a native promise returns that same promise rather than wrapping it, and await unwraps thenables recursively, so you can never end up awaiting a promise and getting a promise back. There is no such thing as a usefully nested promise value.

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

sleep

The one-liner every other answer on this page is built out of. Know it cold.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// await sleep(250);

// Cancellable version, if they push:
function sleepUntil(ms, signal) {
  return new Promise((resolve, reject) => {
    const id = setTimeout(resolve, ms);
    if (signal) {
      signal.addEventListener('abort', () => {
        clearTimeout(id);
        reject(new Error('aborted'));
      }, { once: true });
    }
  });
}
  • setTimeout(resolve, ms) works because resolve ignores the extra arguments a timer would pass; no wrapper arrow is needed.
  • This is a legitimate use of the promise constructor: it is wrapping a callback API that is not promise-based.
  • The cancellable version is worth showing unprompted - a sleep you cannot cancel leaks a timer in every component that unmounts mid-wait.

promisify

Turn an error-first callback API into a promise-returning one. The classic 'show me you understand both models' question.

function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      fn.call(this, ...args, (err, ...values) => {
        if (err) return reject(err);
        resolve(values.length > 1 ? values : values[0]);
      });
    });
  };
}

// const readFile = promisify(fs.readFile);
// const text = await readFile('notes.txt', 'utf8');
  • function rather than an arrow for the wrapper, and fn.call(this, ...) - otherwise promisifying a method loses its receiver and this is undefined inside it.
  • Collecting ...values handles the handful of APIs that call back with more than one result. Node's own util.promisify resolves only the first unless the function carries a custom symbol.
  • Say out loud that this is the only place the promise constructor belongs: wrapping something that is not already a promise.

retry with exponential backoff and jitter

Asked constantly, usually as 'now make it not hammer the server'.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function retry(fn, times = 3, baseDelay = 200) {
  let lastError;

  for (let attempt = 0; attempt < times; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      lastError = err;
      if (err && err.retryable === false) throw err;
      if (attempt === times - 1) break;

      const backoff = baseDelay * Math.pow(2, attempt);
      const jitter = Math.random() * backoff;
      await sleep(backoff + jitter);
    }
  }

  throw lastError;
}

// const data = await retry(() => fetchJson('/api/report'), 4);
  • return await fn() rather than return fn() - without the await, a rejection escapes the try/catch and no retry ever happens.
  • Jitter is the part people leave out. Without it, every client that failed together retries together and re-floods the service the instant it recovers.
  • Mention what you would NOT retry: 4xx responses, and any non-idempotent write without an idempotency key.

fetchWithTimeout

The AbortController question, in the form it is usually asked.

async function fetchWithTimeout(url, options = {}, ms = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);

  try {
    const res = await fetch(url, { ...options, signal: controller.signal });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    return await res.json();
  } catch (err) {
    if (err.name === 'AbortError') throw new Error('Timed out after ' + ms + 'ms');
    throw err;
  } finally {
    clearTimeout(timer);
  }
}
  • finally clears the timer on every path - without it a fast response leaves a pending timer that fires an abort on an already-finished request.
  • fetch does not reject on a 404 or a 500; only a network failure or an abort rejects. The explicit res.ok check is the part interviewers look for.
  • The Promise.race version does NOT cancel the request. Say that unprompted - it is the difference between knowing the API and knowing why it exists.

Promise.all from scratch

A common whiteboard exercise, and it proves you understand what the combinators are doing.

function all(promises) {
  return new Promise((resolve, reject) => {
    const items = Array.from(promises);
    const results = new Array(items.length);
    let remaining = items.length;

    if (remaining === 0) return resolve(results);

    items.forEach((item, i) => {
      Promise.resolve(item).then((value) => {
        results[i] = value;
        remaining -= 1;
        if (remaining === 0) resolve(results);
      }, reject);
    });
  });
}
  • Promise.resolve(item) first, because the input may contain plain values, not just promises.
  • The empty-array early return is the case people forget: with no items, remaining never reaches zero and the promise hangs forever.
  • Count down with a counter, do not check results.length - an array with holes still reports its full length.
  • allSettled is the same shape with the rejection handler writing { status: 'rejected', reason } instead of calling reject.

Run N at a time

'You have 500 URLs and the API allows 5 concurrent requests.' The follow-up to Promise.all, and the one that separates people.

async function mapWithConcurrency(items, worker, limit = 5) {
  const results = new Array(items.length);
  let next = 0;

  async function runner() {
    while (true) {
      const i = next++;
      if (i >= items.length) return;
      results[i] = await worker(items[i], i);
    }
  }

  const runners = Array.from({ length: Math.min(limit, items.length) }, runner);
  await Promise.all(runners);
  return results;
}

// const pages = await mapWithConcurrency(urls, (u) => fetchJson(u), 5);
  • Every runner pulls the next index from one shared counter, so all five slots stay busy for the whole run. Chunking into batches of five instead makes every batch wait for its slowest member.
  • next++ is safe with no lock precisely because JavaScript is single-threaded - worth saying out loud, it lands well.
  • One rejection currently kills the whole thing via Promise.all. If partial results matter, wrap the await worker(...) in a try/catch and store the error in the results slot.
← The event loop, microtasks and output orderEvent emitters, debounce and throttle →