JavaScript Part 2

Promises and async/await From the Ground Up

Promise states, microtask order, a promise built by hand, async/await, the combinators and AbortController.

Lesson 0230 min read#promises#async-await#abortcontroller

On this page

  1. Words used in this lesson
  2. Part 1. The problem: callbacks
  3. Part 2. A promise is a one-time value with three states
  4. Part 3. then, catch, finally: the chaining rules
  5. Part 4. Microtasks: why the order is what it is
  6. Part 5. Build a tiny Promise by hand
  7. Part 6. async/await is sugar
  8. Part 7. The combinators
  9. Part 8. Errors and unhandled rejections
  10. Part 9. Cancellation with AbortController
  11. Try it
  12. Interview questions
  13. Key takeaways
  14. What's next

In one line: a promise is an object that stands for one value that will arrive later (or an error). It starts as pending, settles once, and lets you attach code with then. async/await is just a nicer way to write the same promise chains. Almost every "order of output" puzzle is solved by one rule: promise callbacks run in the microtask queue, after the current code and before the next timer.

This lesson builds promises from the problem they solve, then builds a small one by hand and runs it. It assumes you know the call stack and the event loop, and that functions can be passed as values (first-class functions). For interview question banks see Async JavaScript, Promises and the Event Loop and JavaScript Core Interview Questions. For streams and blocking the loop in Node see Node.js Performance Basics.

What I ran / checked. Everything was run with Node v22.22.0 on Linux (CommonJS files unless I say .mjs). Output is pasted as printed, except that I cut Node's long stack traces. Millisecond timings are from this machine, rounded, and only show the shape (sequential against parallel); they are not benchmarks. For unhandled rejections I read the --unhandled-rejections section of doc/api/cli.md in the nodejs/node repository and ran the cases. I did not run anything in a browser, and browsers differ on the unhandled-rejection part.

Words used in this lesson

  • Asynchronous means "the result comes later, and the program does not wait". The code after the call runs first.
  • Callback is a function you hand to someone else to call later.
  • Promise is an object for a value that is not ready yet.
  • Settled means a promise is no longer pending. It is either fulfilled (has a value) or rejected (has a reason, usually an Error).
  • Microtask is a small job in a queue that the engine runs right after the current code finishes, before timers and I/O.
  • Thenable is any object with a then method. Executor is the function you give to new Promise(...).
  • Unhandled rejection is a rejected promise that has no handler by the time the engine checks.

Part 1. The problem: callbacks

Reading a file or calling a server takes time. JavaScript has one thread (see the engine lesson), so it cannot wait. The old answer was a callback: "call this function when you are done". In Node the old style is error-first: the callback gets (err, data).

When one step needs the result of the previous one, the callbacks nest:

JavaScript
const delay = (ms, cb) => setTimeout(cb, ms);delay(5, () => { console.log("step 1");  delay(5, () => { console.log("step 2");    delay(5, () => { console.log("step 3 (pyramid)"); });  });});const sleep = (ms) => new Promise((r) => setTimeout(r, ms));sleep(30).then(() => { console.log("p step 1"); return sleep(5); }).then(() => { console.log("p step 2"); return sleep(5); }).then(() => console.log("p step 3 (flat)"));

Output:

Text
step 1step 2step 3 (pyramid)p step 1p step 2p step 3 (flat)

The pyramid is "callback hell". Its real problems are not the shape:

  1. Error handling is manual. Every callback needs its own if (err). A try/catch around the outer call cannot catch an error thrown later in an inner callback.
  2. Inversion of control. You give your code to a library and trust it to call you once, not twice, not never.
  3. No value you can hold. You cannot return the "result so far" or pass it around.

A promise fixes these: it is a value you can pass around, it settles once, and errors flow down a chain.

Part 2. A promise is a one-time value with three states

JavaScript
const p = new Promise((resolve, reject) => { console.log("executor runs now"); resolve(1); resolve(2); reject(new Error("late")); });console.log("after constructor", p);p.then((v) => console.log("then got", v));const pending = new Promise(() => {});const rejected = Promise.reject("nope"); rejected.catch(() => {});console.log(pending, rejected);console.log(Promise.resolve(5));

Output:

Text
executor runs nowafter constructor Promise { 1 }Promise { <pending> } Promise { <rejected> 'nope' }Promise { 5 }then got 1
Text
            resolve(value)             state = fulfilled, has a valuepending  ---------------------------->   |        reject(reason)             state = rejected, has a reason   +---------------------------------->

Rules this shows:

  • The executor runs immediately and synchronously. "executor runs now" came first.
  • A promise settles once. The second resolve(2) and the reject were ignored. The value stays 1.
  • Once settled, a promise never changes. Anyone who calls .then later still gets the same value.
  • then callbacks never run synchronously, even for a promise that is already settled. "then got 1" printed last, after all the synchronous lines. This keeps the order predictable.
  • Promise.resolve(v) and Promise.reject(r) make an already-settled promise.

Part 3. then, catch, finally: the chaining rules

p.then(onFulfilled, onRejected) returns a new promise. That is why you can chain. What the new promise becomes depends on what your callback does:

Your callback...The new promise...
returns a normal value xfulfills with x
returns a promise or thenablefollows it (waits, then copies its result)
throwsrejects with the thrown error
is missing (not a function)passes the value or error through unchanged
JavaScript
Promise.resolve(1)  .then((v) => { console.log("A", v); return v + 1; })  .then((v) => { console.log("B", v); return new Promise((r) => setTimeout(() => r(v * 10), 10)); })  .then((v) => { console.log("C", v); throw new Error("oops"); })  .then(() => console.log("skipped"))  .catch((e) => { console.log("caught", e.message); return "recovered"; })  .then((v) => console.log("D", v))  .finally(() => { console.log("finally"); return "ignored"; })  .then((v) => console.log("E", v));const same = Promise.resolve(1);console.log(same.then(() => {}) === same);Promise.resolve(1).then(2).then((v) => console.log("non-function ignored:", v));Promise.resolve().then(() => { throw 1; }).finally(() => console.log("fin2")).catch((x) => console.log("still rejected with", x));

Output:

Text
falseA 1B 2non-function ignored: 1fin2still rejected with 1C 20caught oopsD recoveredfinallyE undefined

(The two small chains and the long chain interleave. Follow each by its own letters.) Read the long chain:

  1. A returns 2, so the next then gets 2 (B 2).
  2. B returns a promise that resolves after 10 ms, so the chain waits and then continues with its value 20 (C 20).
  3. C throws. The next then(() => ...) has no rejection handler, so it is skipped: the error passes through. "skipped" was never printed.
  4. .catch receives the error. Returning "recovered" makes the chain fulfilled again, so D recovered.
  5. .finally(fn) runs fn with no arguments, whatever happened. It ignores the return value (E undefined: the value from D was undefined, and "ignored" did not replace it). If fn throws, the chain rejects. The fin2 chain shows that finally does not stop a rejection (still rejected with 1).
  6. then returns a new promise each time (false), and a non-function argument (then(2)) is ignored, so the value 1 passed through.

p.then(a, b) is not the same as p.then(a).catch(b). In the first form, b handles errors from p only, not errors thrown by a. I ran it: an error thrown in a was caught by the following .catch, and b never ran. Usually you want .then(a).catch(b).

Part 4. Microtasks: why the order is what it is

Your callbacks do not run at once. When a promise settles (or is already settled), the engine puts the callback in the microtask queue. The event loop (see Async JS and the Event Loop) does this:

  1. Run the current script or callback to completion.
  2. Run all microtasks, including new ones added while it runs, until the queue is empty.
  3. Only then take the next task (a timer, an I/O callback).

So microtasks always beat timers, even setTimeout(fn, 0). A traced example:

JavaScript
console.log("1 sync");setTimeout(() => console.log("2 timeout"), 0);Promise.resolve().then(() => console.log("3 promise 1")).then(() => console.log("4 promise 2"));queueMicrotask(() => console.log("5 microtask"));process.nextTick(() => console.log("6 nextTick"));(async () => { console.log("7 async start"); await null; console.log("8 after await"); })();console.log("9 sync end");

Output (CommonJS):

Text
1 sync7 async start9 sync end6 nextTick3 promise 15 microtask8 after await4 promise 22 timeout

Trace:

  1. Sync lines run first: 1, then 7 (an async function runs synchronously until its first await), then 9.
  2. Registered along the way: timer (task queue); then for 3 (microtask queue, because the promise is already resolved); queueMicrotask for 5; the await null continuation for 8; and nextTick for 6.
  3. process.nextTick is a Node-only queue. It runs before promise microtasks when the main script ends in CommonJS, so 6 comes first.
  4. Then the microtasks run in the order they were added: 3, 5, 8. Running 3 added 4 to the end of the queue, so 4 came after 8.
  5. The timer runs last: 2.

I also ran the same file as an ES module (.mjs). Only one thing changed: 6 nextTick moved to after 4 promise 2, just before 2 timeout.

In an ES module the whole file runs inside a promise job, so we are already in the microtask phase when it ends, and nextTick waits until the queue is empty. Do not rely on mixing nextTick and promises. The browser has no nextTick.

Resolving with a promise costs extra rounds

When you resolve a promise with another promise (or return one from then), the engine needs extra microtask rounds to unwrap it. In a test I ran, a chain Promise.resolve().then(a1).then(a2).then(a3) and new Promise(r => r(Promise.resolve())).then(b1) printed b1 only after a3, two rounds later than a plain then would. Do not write code that depends on exact round counts.

Part 5. Build a tiny Promise by hand

Building one removes the magic. The rules we need: a state, a value, a list of waiting handlers; settle once; handlers run in a microtask; then returns a new promise; a returned thenable is followed.

JavaScript
class MyPromise {  #state = "pending"; #value; #handlers = [];  constructor(executor) {    const resolve = (v) => this.#settle("fulfilled", v);    const reject = (e) => this.#settle("rejected", e);    try { executor(resolve, reject); } catch (e) { reject(e); }  }  #settle(state, value) {    if (this.#state !== "pending") return;                 // settle once    if (state === "fulfilled" && value && typeof value.then === "function") {      let called = false;      try {        value.then((v) => { if (!called) { called = true; this.#settle("fulfilled", v); } },                   (e) => { if (!called) { called = true; this.#settle("rejected", e); } });      } catch (e) { if (!called) this.#settle("rejected", e); }      return;    }    this.#state = state; this.#value = value;    this.#handlers.forEach((h) => this.#run(h));    this.#handlers = [];  }  #run({ onFulfilled, onRejected, resolve, reject }) {    queueMicrotask(() => {                                   // handlers always run async      const cb = this.#state === "fulfilled" ? onFulfilled : onRejected;      if (typeof cb !== "function") {                         // pass through        return this.#state === "fulfilled" ? resolve(this.#value) : reject(this.#value);      }      try { resolve(cb(this.#value)); } catch (e) { reject(e); }    });  }  then(onFulfilled, onRejected) {    return new MyPromise((resolve, reject) => {      const h = { onFulfilled, onRejected, resolve, reject };      if (this.#state === "pending") this.#handlers.push(h); else this.#run(h);    });  }  catch(onRejected) { return this.then(undefined, onRejected); }}console.log("start");new MyPromise((res) => setTimeout(() => res(20), 10))  .then((v) => { console.log("got", v); return v + 1; })  .then((v) => { console.log("got", v); throw new Error("fail"); })  .then(() => console.log("skipped"))  .catch((e) => { console.log("caught", e.message); return "ok"; })  .then((v) => console.log("end", v));new MyPromise((res) => res(1)).then((v) => console.log("sync-resolved, still async:", v));console.log("sync end");

Output:

Text
startsync endsync-resolved, still async: 1got 20got 21caught failend ok

How it works, in five lines:

  • #settle sets the state once and then runs every waiting handler. If the value is a thenable, it follows it by calling its then instead of settling now.
  • #run wraps each handler in queueMicrotask. That is the whole "never synchronous" rule.
  • then returns a new MyPromise. Its resolve is called with whatever your callback returns, and its reject with whatever it throws. That is the table from Part 3.
  • If your callback is not a function, #run passes the value or error on. That is the pass-through rule.
  • catch(f) is only then(undefined, f).

It lacks things the real one has (a rule against resolving a promise with itself, exact tick counts), so do not use it in production. But all the chaining behaviour from Part 3 comes out of these few lines.

Part 6. async/await is sugar

An async function always returns a promise. await x pauses that function (not the thread), waits for the promise x, and resumes it in a microtask with the value, or throws the rejection reason at that line.

JavaScript
const sleep = (ms, v, fail) => new Promise((res, rej) => setTimeout(() => (fail ? rej(new Error(v)) : res(v)), ms));async function main() {  const r = async () => { throw new Error("from async"); };  try { await r(); } catch (e) { console.log("caught:", e.message); }  const t0 = Date.now();  await sleep(50, "a"); await sleep(50, "b");  const seq = Date.now() - t0;  const t1 = Date.now();  const both = await Promise.all([sleep(50, "a"), sleep(50, "b")]);  const par = Date.now() - t1;  console.log(both, "sequential ~" + Math.round(seq / 10) * 10 + "ms, parallel ~" + Math.round(par / 10) * 10 + "ms");  const log = [];  [1, 2, 3].forEach(async (n) => { await sleep(5); log.push(n); });  console.log("after forEach:", log);  await sleep(30);  console.log("later:", log);  for (const n of [1, 2]) { await sleep(1); log.push("for" + n); }  console.log(log);  async function a() { try { return sleep(1, "x", true); } catch { return "never"; } }  async function b() { try { return await sleep(1, "x", true); } catch { return "handled"; } }  console.log(await a().catch((e) => "a rejected: " + e.message), await b());}main();

Output:

Text
caught: from async[ 'a', 'b' ] sequential ~100ms, parallel ~50msafter forEach: []later: [ 1, 2, 3 ][ 1, 2, 3, 'for1', 'for2' ]a rejected: x handled

What it shows:

  • try/catch works around await. This is the biggest gain over callbacks: one error style for sync and async code.
  • Sequential against parallel. Two awaits one after the other took about 100 ms. Starting both first and awaiting Promise.all took about 50 ms. Await only when you need the result. (These timings are from this machine and are rounded.)
  • forEach does not wait. It ignores the promises its callback returns, so "after forEach" saw an empty list. Use for...of with await for one-by-one, or await Promise.all(items.map(async ...)) for parallel.
  • return against return await. Inside try, return promise leaves the function before the rejection happens, so the catch never ran (a rejected: x). With return await, the catch ran (handled).
  • The code before the first await runs synchronously; everything after it runs as a microtask. That is why 7 async start printed in the sync part in Part 4.

Part 7. The combinators

FunctionFulfills whenRejects whenResult
Promise.all(list)all fulfillany rejects (first one)array of values, in input order
Promise.allSettled(list)all settleneverarray of {status, value} or {status, reason}
Promise.race(list)the first settles with a valuethe first settles with an errorthat first result
Promise.any(list)the first fulfillsall reject: AggregateErrorthe first value
JavaScript
const sleep = (ms, v, fail) => new Promise((res, rej) => setTimeout(() => (fail ? rej(new Error(v)) : res(v)), ms));(async () => {  const show = (label, p) => p.then((v) => console.log(label, "->", v), (e) => console.log(label, "-> rejected:", e.constructor.name, e.message));  await show("all ok", Promise.all([sleep(10, 1), sleep(5, 2), 3]));  await show("all fail", Promise.all([sleep(10, 1), sleep(5, "bad", true)]));  await show("allSettled", Promise.allSettled([sleep(5, 1), sleep(5, "bad", true)]));  await show("race", Promise.race([sleep(20, "slow"), sleep(5, "fast")]));  await show("race reject", Promise.race([sleep(20, "slow"), sleep(5, "err", true)]));  await show("any", Promise.any([sleep(5, "e1", true), sleep(10, "winner"), sleep(1, "e2", true)]));  await show("any none", Promise.any([sleep(5, "e1", true), sleep(1, "e2", true)]));  await show("all empty", Promise.all([]));  await show("any empty", Promise.any([]));})();

Output (I removed the stack trace that Node printed inside the allSettled reason):

Text
all ok -> [ 1, 2, 3 ]all fail -> rejected: Error badallSettled -> [  { status: 'fulfilled', value: 1 },  { status: 'rejected', reason: Error: bad ... }]race -> fastrace reject -> rejected: Error errany -> winnerany none -> rejected: AggregateError All promises were rejectedall empty -> []any empty -> rejected: AggregateError All promises were rejected
  • all: results keep input order ([1, 2, 3]), not finish order. Plain values are allowed.
  • all fails fast. The other promises are not cancelled; they keep running and their results are ignored.
  • allSettled is for "try all, report each". Use it for batch jobs.
  • race takes the first settled, good or bad. A classic use is a timeout.
  • any ignores errors until one succeeds. Use it for "try mirrors, take the first answer".
  • Edge cases: all([]) fulfills with [], but any([]) rejects.

Limiting parallelism

Promise.all(items.map(fetchOne)) starts everything at once. For 10,000 items, that can overload a server. A small pool has N workers that each pull the next task in a loop (while (i < tasks.length) { const k = i++; res[k] = await tasks[k](); }, started N times inside Promise.all). I ran it with four 20 ms tasks and a limit of 2: it returned [ 1, 2, 3, 4 ] in about 40 ms (two rounds of two). With Promise.all all four would start together. Each worker is an async loop; i++ is safe because JavaScript runs one piece of code at a time.

Part 8. Errors and unhandled rejections

An error thrown in an executor, in a then callback, or in an async function becomes a rejection (I ran all three; each reached a .catch). Errors thrown in a plain setTimeout callback are not caught by a promise; they need their own try/catch. Forgetting await is a common bug: you get a promise object (Promise { 'data' } in my run) where you wanted data.

What Node does with an unhandled rejection

After a rejection happens with no handler attached, Node waits for the microtask queue to drain. If there is still no handler, it follows the --unhandled-rejections mode. The default (since Node 15) is throw: emit an unhandledRejection event; if nobody listens, raise it as an uncaught exception, which ends the process. I ran:

JavaScript
const p = Promise.reject(new Error("nobody handles me"));setTimeout(() => console.log("timer still ran?"), 50);

The process printed the error with a stack trace, ended with the line Node.js v22.22.0, never printed "timer still ran?", and the exit code was 1. With node --unhandled-rejections=warn it printed an UnhandledPromiseRejectionWarning instead and went on.

You can listen for the events. This also shows the late-handler case:

JavaScript
process.on("unhandledRejection", (reason, promise) => { console.log("unhandledRejection:", reason.message); });process.on("rejectionHandled", () => console.log("rejectionHandled (handler added late)"));const p = Promise.reject(new Error("late handler"));setTimeout(() => p.catch(() => console.log("handled now")), 20);

Output:

Text
unhandledRejection: late handlerhandled nowrejectionHandled (handler added late)

The promise was reported as unhandled because the catch came 20 ms later. Attach handlers in the same tick as you create the promise. In production, log in an unhandledRejection handler and then let the process restart; do not just hide it. (Browsers fire a similar unhandledrejection event on window; I did not run that.)

Part 9. Cancellation with AbortController

A promise cannot be cancelled. What you can do is make the work stop and reject. The standard tool is AbortController: it gives you a signal to pass into the work, and a method abort() to trigger it.

JavaScript
const sleep = (ms, signal) => new Promise((res, rej) => {  signal?.throwIfAborted();  const t = setTimeout(res, ms, "done");  signal?.addEventListener("abort", () => { clearTimeout(t); rej(signal.reason); }, { once: true });});(async () => {  const ac = new AbortController();  setTimeout(() => ac.abort(), 20);  try { await sleep(1000, ac.signal); } catch (e) { console.log("1:", e.name, "|", e.message, "|", ac.signal.aborted); }  try { await sleep(1000, AbortSignal.timeout(20)); } catch (e) { console.log("2:", e.name, "|", e.message); }  const ac2 = new AbortController(); ac2.abort(new Error("custom reason"));  try { await sleep(10, ac2.signal); } catch (e) { console.log("3:", e.message); }  const server = require("http").createServer((req, res) => setTimeout(() => res.end("slow"), 500)).listen(0);  const url = `http://127.0.0.1:${server.address().port}`;  try { await fetch(url, { signal: AbortSignal.timeout(50) }); } catch (e) { console.log("4 fetch:", e.name, "|", e.message); }  try { const ac4 = new AbortController(); setTimeout(() => ac4.abort(), 50); await fetch(url, { signal: ac4.signal }); } catch (e) { console.log("5 fetch:", e.name, "|", e.message); }  server.closeAllConnections(); server.close();})();

Output:

Text
1: AbortError | This operation was aborted | true2: TimeoutError | The operation was aborted due to timeout3: custom reason4 fetch: TimeoutError | The operation was aborted due to timeout5 fetch: AbortError | This operation was aborted
  • A signal fires an abort event once. If it is already aborted when you start, the event will not fire again, so check signal.aborted or call signal.throwIfAborted() first (case 3). My first version without that check did not reject: it waited 10 ms and returned "done".
  • AbortSignal.timeout(ms) is a ready-made signal that aborts with a TimeoutError (cases 2 and 4). A normal abort() gives an AbortError (cases 1 and 5). So you can tell "user cancelled" from "too slow".
  • fetch accepts { signal }. The request is cancelled and the promise rejects.
  • Typical use: in a React effect or a search box, abort the previous request when a new one starts, so an old slow answer cannot overwrite a newer one.

Try it

  1. Predict the order: console.log("a"); setTimeout(() => console.log("b")); Promise.resolve().then(() => console.log("c")); console.log("d");
  2. What does Promise.resolve(1).then(2).then(console.log) print?
  3. async function f() { try { return Promise.reject(new Error("x")); } catch { return "caught"; } } What does await f() do?
  4. How would you add a 2-second timeout to any promise-returning function, using only what is in this lesson?
  5. You have an array of 5 user IDs. Write code that loads them in parallel, keeps going if some fail, and prints how many succeeded.

Answers:

  1. a d c b. Sync first (a, d), then the microtask (c), then the timer (b). I ran it and got that order.
  2. It prints 1 (I ran it). A non-function argument to then is ignored, so the value passes through (shown in Part 3).
  3. It throws (rejects) with x; "caught" is never returned. Without await on the returned promise, the try block is already left. Use return await. I ran it: the promise rejected with x.
  4. Promise.race([work(), new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 2000))]), I ran the race version with 20 ms against a 100 ms job and got timeout. Better is AbortSignal.timeout(2000) passed to the work, because race does not stop the slow work. Also clear the timer if the work wins.
  5. const rs = await Promise.allSettled(ids.map(loadUser)); const ok = rs.filter(r => r.status === "fulfilled").length; console.log(ok + " of " + rs.length); I ran it with a loadUser that fails for odd IDs and it printed 2 of 5.

Interview questions

What is a promise and what are its states? (Must know)

A promise is an object that represents a value that will be available later. It is pending at first. It then becomes fulfilled with a value or rejected with a reason, exactly once, and never changes after that. Handlers added with then, catch or finally run asynchronously, in the microtask queue.

What does then return, and how do chains work? (Must know)

then always returns a new promise. If my callback returns a value, the new promise fulfills with it. If I return a promise, the new one follows it. If I throw, it rejects. If I pass no handler for a case, the result passes through. So an error skips then steps until a catch, and a catch that returns normally turns the chain back to fulfilled.

Give the output order of sync code, setTimeout(0), a promise then and process.nextTick. (Must know)

Sync code first. Then in Node CommonJS: nextTick queue, then promise microtasks, then timers. In the browser there is no nextTick: sync, microtasks, then timers. In an ES module in Node, promise microtasks can run before nextTick, because the module itself runs inside a promise job. I would say the safe answer: microtasks always run before the next timer.

How would you implement a simple Promise? (Senior signal)

Keep a state, a value and a list of handlers. resolve and reject change the state only if still pending, then run the handlers. then returns a new promise and wraps each handler in queueMicrotask. It resolves the new promise with the callback's return value (following it if it is a thenable) or rejects it if the callback throws. If the callback is not a function, it passes the value on.

What is the difference between Promise.all, allSettled, race and any? (Must know)

all waits for all successes and fails on the first failure. allSettled waits for all and reports each outcome. race settles with the first result, good or bad. any fulfills with the first success and rejects with an AggregateError only if all fail. None of them cancel the other promises.

Sequential or parallel: what is the cost of a careless await? (Must know)

Two independent calls awaited one after the other take the sum of both times. Starting both first and then awaiting them together takes the longer one. In my run it was about 100 ms against 50 ms. I await only when the next line needs the result. I limit parallelism with a pool when the number of items is large.

What happens with an unhandled rejection in Node? (Senior signal)

Since Node 15 the default mode is throw: an unhandledRejection event is emitted, and if no listener exists the error becomes an uncaught exception and the process exits with code 1. In my test a timer scheduled after the rejection never ran. I always attach a catch, and I add a global handler that logs and lets the process restart.

A promise cannot be cancelled. How do you stop work that is not needed any more? (Senior signal)

I use AbortController. I pass its signal to fetch or to my own function. On abort() the work stops and the promise rejects with the signal's reason. AbortSignal.timeout(ms) gives a timeout with a TimeoutError. I must handle an already-aborted signal with throwIfAborted(), because the event fires only once. In a UI, I abort the previous request when a new one starts.

Key takeaways

  • A promise is a one-time value: pending, then fulfilled or rejected, once. Handlers always run asynchronously as microtasks, before the next timer.
  • then returns a new promise: a returned value fulfills it, a returned promise is followed, a throw rejects it, and a missing handler passes the result through.
  • async/await is the same promises with try/catch. Code before the first await runs synchronously.
  • Await only when you need the value. Use Promise.all for independent work, allSettled when you want every outcome, and a pool for large lists.
  • forEach does not wait. return await is needed inside try/catch.
  • Node ends the process on an unhandled rejection by default. Handle errors close to where you create the promise.
  • Cancel work with AbortController and AbortSignal.timeout, and check signal.aborted before you start.

What's next

Next: Modules: ES Modules, CommonJS, Bundlers and Tree Shaking, Lesson 3. Practise: write a retry(fn, times) that uses await in a loop, then add an AbortSignal so a caller can stop the retries.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series