Full-Stack Questions
Async JavaScript, Promises and the Event Loop
Order puzzles in the browser and Node, promise rules, combinators, concurrency limits, cancellation, race conditions and async memory leaks.
On this page
- Words used in this lesson
- The event loop: browser vs Node
- Callbacks, promises, async/await
- Chaining rules: return vs throw
- Errors and unhandled rejections
- The combinators
- Sequential, parallel and limited concurrency
- Async iteration
- Cancellation, timeouts and retries
- Race conditions and stale responses
- Async in loops
- Top-level await, thenables and starvation
- Workers, SharedArrayBuffer and Atomics
- fetch details
- Events: delegation, bubbling, capture, passive
- Yielding, idle time, debounce and throttle
- Memory leaks from async code
- Interview questions
- Key takeaways
- What's next
In one line: JavaScript runs one piece of code at a time, and everything async is just a rule about who gets the next turn: synchronous code first, then all microtasks (promise callbacks), then the next task (a timer, a click, a network reply). Learn that rule, and the output-order puzzles, the race conditions and the "why is my UI frozen" bugs all become easy to explain.
At 4+ years, an interviewer will not ask "what is a promise?". They show you ten lines of code and ask "what does this print, and why?". Then they ask you to write a concurrency limiter, cancel a request, or explain a stale search result. This lesson adds new angles to Async JS and the Event Loop and Browser Performance, Lesson 8. Every output below was run in Node 22. Every browser-only result was checked in Chromium 141.
Words used in this lesson
- The event loop is a loop that takes one piece of waiting work, runs it until it finishes, and then takes the next one. JavaScript has one thread, so this loop is the only way to "do many things".
- A task (also called a macrotask) is one item of work in the loop, such as a timer callback, a click handler or a network reply. A microtask is a tiny job that must run right after the current code ends, before the next task. Promise callbacks are microtasks. Difference in one line: a task is a whole turn, a microtask is a quick extra step at the end of a turn.
- A promise is an object that stands for a result that is not ready yet. It is pending first, then it becomes fulfilled (has a value) or rejected (has an error). Once settled, it never changes.
- A thenable is any object that has a
.thenmethod. Promises are thenables, but not every thenable is a promise. - A callback is a function you pass to another function so it can call you back later.
- A race condition is a bug where the result depends on which of two async actions finishes first.
- Concurrency means several jobs are in progress at the same time (JavaScript does this by switching, not by running two lines at once). Parallelism means two things really run at the same moment (workers do this).
- Backoff means waiting longer between each retry. Jitter means adding a random part to that wait.
The event loop: browser vs Node
Both have the same core rule: one task, then all microtasks, then the next task. What differs is what lives around that rule.
| Browser | Node.js | |
|---|---|---|
| Task sources | clicks, timers, postMessage, network, ... | timers, I/O, setImmediate, close events |
| Microtasks | promises, queueMicrotask, MutationObserver | promises, queueMicrotask, plus process.nextTick queue |
| Rendering | a step between tasks: requestAnimationFrame callbacks, style, layout, paint | none |
| "Run soon, after this" | queueMicrotask, setTimeout(fn, 0) | queueMicrotask, setImmediate, setTimeout(fn, 0) |
| Loop shape | one task, microtasks, maybe render | fixed phases in a circle |
The Node phases, in order: timers (due setTimeout/setInterval), pending callbacks, poll (wait for and run I/O callbacks), check (setImmediate callbacks), close callbacks. After each callback, Node empties the process.nextTick queue, then the microtask queue, and repeats until both are empty. (Before Node 11, this happened only between phases. Old blog posts still show the old behaviour.)
The rule about nextTick is the one people get wrong. process.nextTick(fn) adds fn to a queue that runs before the promise queue in a CommonJS file. In an ES module file, the module itself runs inside a promise job, so promise callbacks run first. The Node docs now mark process.nextTick as Legacy (since Node 22.7 and 20.18) and tell you to use queueMicrotask instead.
Puzzle 1: all the queues at once
// order.cjsconsole.log("A");setTimeout(() => console.log("timeout"), 0);setImmediate(() => console.log("immediate"));process.nextTick(() => console.log("nextTick"));Promise.resolve().then(() => console.log("promise"));queueMicrotask(() => console.log("microtask"));console.log("B");| File type | Output |
|---|---|
CommonJS (.cjs) | A B nextTick promise microtask then immediate and timeout in either order |
ES module (.mjs) | A B promise microtask nextTick then immediate and timeout in either order |
Why is the last pair random? From the main script, Node starts the loop and checks the timer. If less than 1 ms has passed, the timer is not due yet, so setImmediate wins. If the machine was slow to start, the timer is due and wins. I ran it six times and saw both orders. Inside an I/O callback, the order is fixed: setImmediate always runs first, because the loop is in the poll phase and the check phase comes next.
const fs = require("fs");fs.readFile(__filename, () => { setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate")); process.nextTick(() => console.log("nextTick")); Promise.resolve().then(() => console.log("promise"));});// nextTick, promise, immediate, timeout (always)Puzzle 2: await and the queue
async function a1() { console.log("a1 start"); await a2(); console.log("a1 end"); }async function a2() { console.log("a2"); }console.log("script start");setTimeout(() => console.log("setTimeout"), 0);a1();new Promise((resolve) => { console.log("p1"); resolve(); }).then(() => console.log("p2"));console.log("script end");Output: script start, a1 start, a2, p1, script end, a1 end, p2, setTimeout.
Reason: code before the first await runs synchronously, even inside an async function (a1 start, a2). The new Promise executor (the function you pass in) also runs synchronously (p1). await pauses a1 and puts its rest in the microtask queue, so a1 end comes before p2 because it was queued first. The timer is a task, so it comes last.
Puzzle 3: two chains take turns
Promise.resolve().then(() => console.log("A1")).then(() => console.log("A2")).then(() => console.log("A3"));Promise.resolve().then(() => console.log("B1")).then(() => console.log("B2"));(async () => { console.log("C0"); await null; console.log("C1"); await null; console.log("C2"); })();console.log("sync");Output: C0 sync A1 B1 C1 A2 B2 C2 A3. Each .then or await is one "step" and each step goes to the back of the queue, so independent chains interleave one step at a time. Do not memorise results like this. Learn the method: write each callback on a numbered line for tick 1, tick 2, tick 3.
Puzzle 4: how many ticks does a return cost?
async function f1() { return 1; }async function f2() { return Promise.resolve(2); }async function f3() { return await Promise.resolve(3); }// compared with a plain chain t1 -> t2 -> t3 -> t4// result order: f1 t1 f3 t2 f2 t3 t4Returning a plain value takes one tick. return await p takes two. Returning a promise from an async function takes three, because the engine needs extra steps to join the two promises. This is correct and standard, but you should never write code that depends on it. Interviewers ask it to see if you know "return a promise" is not free.
Popular interview material says "nextTick always runs before promises" and "setTimeout(fn, 0) always runs before setImmediate". Both are wrong. The first is false in ES modules. The second is random on the main script.
Browser-only angles
These were checked in Chromium 141.
- Rendering is a step, not a task. After a task and its microtasks, the browser may run
requestAnimationFramecallbacks, then style, layout and paint. It does this only when a frame is due (about every 16.7 ms on a 60 Hz screen). Do not depend on the order of a timer and a rAF callback. - Nested timers are slowed. In Chrome, a
setTimeout(fn, 0)chain that is nested more than about five levels deep waits at least 4 ms per step. I measured0.0 msgaps for the first steps and4.1 msafter that. - A real click and
el.click()differ. For a real user click on a button with two listeners, the log wasR1, microtask from R1, R2: the browser runs microtasks between listeners. Forbutton.click()called from code, it wasL1, L2, microtask, because JavaScript was already on the stack. This surprises people who test with.click()and then see different results from real users. - Hidden tabs are throttled. Timers in a background tab may fire only about once per second. Do not use timers to measure real time. Compare
Date.now()orperformance.now()instead.
Callbacks, promises, async/await
A callback is the oldest tool. It has two problems. First, nesting gets deep ("callback hell"). Second, you hand control to someone else's code, so you must trust they call you once, not twice, and not never. A promise fixes this: it settles once, and your .then is always called later, never synchronously.
async/await is a way to write promise code so it reads like normal top-to-bottom code. An async function is a function that always returns a promise. await pauses that function until a promise settles, and gives you the value or throws the error. It does not block the thread; other code keeps running while you wait.
Rules that matter in interviews:
- An
asyncfunction never throws synchronously. Athrowinside it becomes a rejected promise. - A
new Promise(executor)runs the executor at once, and athrowinside it becomes a rejection. But athrowinside a later callback (like asetTimeoutinside it) is not converted. Wrap that intry/catchand callreject. resolveandrejectwork only once. After the first call, others are ignored.Promise.resolve(p)returnspitself ifpis a native promise.new Promise(r => r(p))makes a new promise that followsp.
Chaining rules: return vs throw
Each .then(onFulfilled, onRejected) returns a new promise. Its fate depends on what your handler does:
| Your handler... | The next promise... |
|---|---|
| returns a value | fulfills with that value |
| returns nothing | fulfills with undefined (the classic "forgot return") |
| returns a promise or thenable | follows it (fulfills or rejects like it) |
| throws | rejects with the thrown error |
is not a function (like .then(2)) | passes the value through unchanged |
const bad = () => Promise.reject(new Error("boom"));// then(a, b): b handles the OLD promise. It does NOT catch errors thrown by a.Promise.resolve(1).then( () => { throw new Error("ok-handler throws"); }, () => console.log("never runs")).catch((e) => console.log("caught:", e.message)); // caught: ok-handler throws// .catch() returns a promise, so the chain continues with the recovered valuePromise.reject(new Error("x")).catch(() => "recovered").then((v) => console.log(v)); // recoveredDifference in one line: .then(a, b) has b guard only the promise before it, but .then(a).catch(b) has b guard a too. Use .then().catch() in most cases.
.finally(fn) runs on both outcomes, gets no argument, and keeps the original result. The only way it changes the result is if fn throws (then the new error replaces it).
The try and return trap
async function a() { try { return fail(); } catch { return "caught in a"; } }async function b() { try { return await fail(); } catch { return "caught in b"; } }// a() rejects! The catch never runs. b() returns "caught in b".Without await, a returns the promise to the caller and leaves the try before the promise rejects. This is why many teams use return await inside try blocks.
Errors and unhandled rejections
An unhandled rejection happens when a promise is rejected and nobody has attached a handler by the time the microtask queue is empty.
| Browser | Node 22 | |
|---|---|---|
| Signal | unhandledrejection event on window (and rejectionhandled if you catch late) | process.on("unhandledRejection") (and "rejectionHandled") |
| If nobody listens | red error in the console, page keeps running | the process prints the error and exits with code 1 (default mode throw since Node 15) |
| Silence it | event.preventDefault() | add a handler; or set --unhandled-rejections=warn (not recommended) |
I ran a rejected promise with a 50 ms late .catch. Node fired unhandledRejection first, then rejectionHandled when the catch arrived. In production, the right move is: catch where you can do something useful, and also add a global handler that logs and (in Node) lets the process exit so your process manager restarts it. A process in an unknown state is more dangerous than a restart. See Node.js & Express, lesson 6 for graceful shutdown.
Other facts that come up:
- If
Promise.allrejects early, the other promises keep running (no cancel), and their later rejections do not raise a new unhandled rejection, becausePromise.allattached handlers to all of them. - A promise you create and
awaitlater can reject in the gap and trigger an unhandled rejection. Attach handlers early, or usePromise.all. Promise.try(fn)(runfnand always get a promise) is standard in Chrome 128, Firefox 134 and Safari 18.2, but not in Node 22 (I checked:typeof Promise.tryis"undefined"). It arrives in Node 23+. In Node 22 usenew Promise(r => r(fn()))or anasyncwrapper.
The combinators
A combinator is a function that takes many promises and gives back one.
| Method | Fulfills when | Rejects when | Empty array | Use it for |
|---|---|---|---|---|
Promise.all | all fulfilled (array in input order) | first rejection | [] | all results needed, fail fast |
Promise.allSettled | all settled (never rejects) | never | [] | batch where partial success is OK |
Promise.race | first settled (win or fail) | first settled is a failure | never settles | timeouts |
Promise.any | first fulfilled | all rejected (AggregateError) | rejects | fastest mirror, fallbacks |
Promise.withResolvers() | you call resolve | you call reject | n/a | turn an event into a promise |
I ran them on three promises: A (30 ms ok), B (10 ms fail), C (20 ms ok). all rejected with B. allSettled gave fulfilled, rejected, fulfilled. race rejected with B. any returned C (the first success). any over only failures gave AggregateError with an errors array of all reasons.
Promise.withResolvers() (ES2024) returns { promise, resolve, reject }, so you no longer need the let resolve; new Promise(r => resolve = r) trick. It works in Node 22, Chrome 119, Firefox 121 and Safari 17.4.
Sequential, parallel and limited concurrency
await sleep(50); await sleep(50); // sequential: ~100 msawait Promise.all([sleep(50), sleep(50)]); // parallel: ~50 msconst p1 = sleep(50), p2 = sleep(50); // start early, await laterawait p1; await p2; // also ~50 msStart the work first, await later. But unlimited parallel is dangerous: 5,000 Promise.all requests can hit a rate limit, exhaust sockets or crash a database. You need a pool (a concurrency limit). This is a very common "implement it" question:
async function pool(tasks, limit) { const results = new Array(tasks.length); let next = 0, failed = false; async function worker() { while (!failed && next < tasks.length) { const i = next++; // safe: no await between read and ++ try { results[i] = await tasks[i](); } catch (e) { failed = true; throw e; } // stop other workers taking new tasks } } await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker)); return results;}tasks is an array of functions that return promises (not promises, because a promise has already started). With 10 tasks and limit = 3, I measured a peak of exactly 3 running at once and results in the right order. Why is next++ safe? JavaScript never switches threads between those two lines. Without the failed flag, the version I first tested kept starting tasks after the first error: 3 had started when it rejected, and 6 by the end.
Other tools: p-limit and p-queue (small, popular packages) do the same with more features. For a stream of work, use a real queue, such as BullMQ (see the libraries lesson).
Async iteration
An async iterator is an object whose next() returns a promise. An async generator (async function*) is a function that can both await and yield. for await...of loops over them and waits for each item. (ES2018; Node 10+, Chrome 63, Firefox 57, Safari 12.)
async function* pages(fetchPage) { let cursor = null; do { const { items, next } = await fetchPage(cursor); yield* items; cursor = next; } while (cursor);}for await (const item of pages(getPage)) { /* one item at a time, next page fetched on demand */ }What I verified:
breakinsidefor awaitcalls the generator'sfinallyblock, so cleanup runs.for awaitalso works on a normal array of promises and on Node streams (Readableis async iterable) and on webReadableStream(Node 22, Chrome 124, Firefox 110).- Values come out one at a time with backpressure: the producer waits for the consumer. That is why reading a huge file with
for await (const chunk of stream)does not fill memory. Array.fromAsync(asyncIterable)collects them into an array (Node 22, Chrome 121, Firefox 115, Safari 16.4).
Cancellation, timeouts and retries
A promise cannot be cancelled. Promise.all rejecting does not stop the others. Cancel the work (the request, the timer) with an AbortController, an object that has a signal you pass to the work and an abort() method you call to say "stop".
fetch(url, { signal: AbortSignal.timeout(5000) }); // gives up after 5 sconst c = new AbortController();fetch(url, { signal: AbortSignal.any([c.signal, AbortSignal.timeout(5000)]) }); // user cancel OR timeoutWhat I observed in Node 22:
| Cause | Error name |
|---|---|
AbortSignal.timeout(50) fires | TimeoutError |
controller.abort() | AbortError |
controller.abort(new Error("x")) | your own Error (the reason is passed through) |
| network down | TypeError ("fetch failed" in Node, "Failed to fetch" in Chrome) |
So catch blocks should check err.name, not just "an error". signal.throwIfAborted() lets your own code stop at checkpoints. AbortSignal.timeout counts active time only (MDN), and AbortSignal.any needs Node 20.3+, Chrome 116, Firefox 124, Safari 17.4.
Retry with backoff and jitter
async function retry(fn, { retries = 3, base = 100, max = 2000, signal, shouldRetry = () => true } = {}) { for (let attempt = 0; ; attempt++) { signal?.throwIfAborted(); try { return await fn(attempt); } catch (err) { if (attempt >= retries || !shouldRetry(err)) throw err; const delay = Math.random() * Math.min(max, base * 2 ** attempt); // "full jitter" await sleep(delay, signal); // sleep that rejects when the signal aborts } }}I tested three cases: a call that fails twice then succeeds (3 calls), a 400 error that was not retried because shouldRetry said no, and a retry loop stopped by an AbortSignal.timeout(60). Rules: retry only safe errors (network failure, 429, 503, and only GET or requests with an idempotency key), put a cap on attempts and on the delay, add jitter so a thousand clients do not retry at the same instant, and honour Retry-After.
Race conditions and stale responses
Search-as-you-type: the user types "re", "rea", "reac". Three requests start. The reply for "re" is slowest, so it arrives last and overwrites the right answer. I reproduced it: the naive code ended up showing results for re.
// Fix 1: only the latest request may writelet id = 0;async function search(q) { const mine = ++id; const r = await load(q); if (mine === id) show(r); }// Fix 2: abort the previous request (also saves bandwidth)let ctrl;async function search2(q) { ctrl?.abort(); ctrl = new AbortController(); const r = await load(q, { signal: ctrl.signal }); // old call rejects with AbortError: ignore it show(r);}Both fixes showed results for reac. In React, the same idea is an effect cleanup (let ignore = false; return () => { ignore = true }), or use TanStack Query, which handles it (see React, lesson 4).
A second kind is check-then-act across an await. if (balance >= n) { await save(); balance -= n } called twice at once took 100 down to -60 in my test, because both checks passed before either update. Fix it with a database-level atomic update (UPDATE ... WHERE balance >= n) or, in one process, a promise chain that runs the calls one at a time. JavaScript is single-threaded, but await is a place where other code can run in between.
Async in loops
items.forEach(async (n) => { await work(n); }); // forEach does not wait: the next line runs firstfor (const n of items) { await work(n); } // sequential, in orderawait Promise.all(items.map(async (n) => work(n))); // parallel, results in input orderI ran all of them. forEach printed "after forEach" before any item finished, and the items finished in time order (3, 2, 1), not list order. for...of ran one by one. map with Promise.all took the time of the slowest only and kept input order. Also: arr.map(async ...) gives an array of promises, and arr.filter(async n => n > 5) keeps everything, because a promise object is always truthy.
Top-level await, thenables and starvation
Top-level await lets you use await outside a function, but only in an ES module (ES2022; Node 14.8+, Chrome 89, Firefox 89, Safari 15). A .cjs file gives SyntaxError: await is only valid in async functions and the top level bodies of modules. A module that awaits makes modules that import it wait, but not unrelated siblings. I ran main importing a (waits 30 ms) and b: the order was a: start, b: start, a: done, main runs last. A slow top-level await slows start-up for everything that depends on it.
A thenable is called by await and Promise.resolve, but in a later microtask, not at once:
console.log("before");Promise.resolve({ then(res) { console.log("then() called"); res("T"); } }).then((v) => console.log("value", v));console.log("after");// before, after, then() called, value TThis is why returning a thenable from .then costs extra ticks, and why any object with a then method is dangerous: a plain data object with a then field gets treated as a promise.
Microtask starvation is when microtasks keep adding more microtasks, so the loop never reaches the next task. I queued 3,000,000 chained queueMicrotask calls and a 1 ms timer: the timer fired after about 480 ms. In a browser, this blocks painting and input too. Chaining setImmediate instead let the timer fire at once, because each immediate is a separate turn.
Workers, SharedArrayBuffer and Atomics
A Web Worker (Node: worker_threads) is a separate thread with its own event loop and memory. It cannot touch the DOM. You talk to it with postMessage, which copies the data (structured clone). You can transfer an ArrayBuffer instead: it moves, and the sender's copy becomes empty (I saw byteLength = 0 after a transfer).
SharedArrayBuffer is a block of memory that several threads can read and write. Two traps:
- Browsers only give it to cross-origin isolated pages, which need the headers
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp(orcredentialless). In my plain test page,typeof SharedArrayBufferwas"undefined". - Shared memory brings real races. Four workers each did 100,000
v[0] = v[0] + 1. Expected: 400,000. I got 275,209. WithAtomics.add(v, 0, 1)I got exactly 400,000.
Atomics is a set of operations (add, compareExchange, wait, notify) that happen as one unbreakable step. Atomics.wait blocks a thread, so it is not allowed on the browser main thread; use Atomics.waitAsync there. Use workers for CPU work (hashing, parsing, image work). Use plain async for waiting on I/O: workers do not make network calls faster.
fetch details
- HTTP errors do not reject. A
404or500still resolves, withres.ok === false. Only a network failure, a CORS block or an abort rejects. Always checkres.ok. - A body can be read once. A second
res.text()throwsTypeError: Body is unusable. Useres.clone()first if you need two reads. - The body is a stream.
res.bodyis aReadableStream, so you can read it chunk by chunk (for await (const chunk of res.body)). This is how streamed AI replies work. keepalive: truelets a request outlive the page (for analytics onpagehide). The total body of in-flight keepalive requests is limited to about 64 KiB: in Chromium 141, a 1 KiB body worked, and a 70 KiB body rejected.navigator.sendBeaconis the older tool for the same job.- Timeouts are not built in. Use
AbortSignal.timeout. fetchdoes not send cookies cross-origin unlesscredentials: "include", and sending a JSON body makes a CORS preflight. (See the browser lessons for CORS.)
Events: delegation, bubbling, capture, passive
Bubbling means an event starts at the clicked element (the target) and travels up through its parents. Capture is the trip down first. Order for a click: capture down, the target, then bubble up. Event delegation means putting one listener on a parent and using event.target.closest(selector) to find which child was clicked. It saves memory and works for items added later.
I tested a click on a span inside an li inside a div#list: the order was list capture, span (target), li bubble, list bubble. In the delegated handler, e.target was the SPAN, e.currentTarget was the list, and e.target.closest("li") found the row. That is the common bug: e.target may be a child of the element you wanted, so use closest.
stopPropagation()stops the trip to other elements.stopImmediatePropagation()also stops other listeners on the same element.preventDefault()only cancels the browser default (a link, a form submit).- Not every event bubbles:
focus,blur,mouseenterdo not (usefocusin,focusout,mouseover). - A passive listener (
{ passive: true }) promises never to callpreventDefault(). Then the browser scrolls without waiting for your code. If you call it anyway, it is ignored and Chrome logs an error (I sawUnable to preventDefault inside passive event listener invocation).wheel,touchstartandtouchmoveonwindow,documentandbodyare passive by default. { once: true }removes the listener after one call.{ signal }removes it when the signal aborts, so oneabort()can clean up many listeners.
Yielding, idle time, debounce and throttle
To keep a page responsive, split long work and give the browser turns:
| Tool | What it does | Support |
|---|---|---|
setTimeout(fn, 0) | new task, works everywhere (4 ms clamp when nested) | all |
scheduler.yield() | pause, then continue before other queued tasks | Chrome 129, Firefox 142, not Safari |
requestAnimationFrame | run just before the next paint | all |
requestIdleCallback | run when the browser is idle; give it { timeout } | Chrome, Firefox; Safari only behind a flag |
In Chromium, the continuation after await scheduler.yield() ran before a setTimeout(0) that was queued earlier. So write a helper: use scheduler.yield if it exists, else setTimeout. Do not use requestIdleCallback for work that must happen, because it may never run on a busy page.
Debounce means "wait until the events stop, then run once". Throttle means "run at most once every N ms while they continue". I fired an event every 30 ms for 300 ms with a 100 ms wait. Debounce ran once, at about 400 ms (after the last event). Throttle ran at about 0, 100, 200 and 300 ms.
function debounce(fn, wait) { let id; return (...a) => { clearTimeout(id); id = setTimeout(() => fn(...a), wait); }; }Use debounce for search boxes and autosave. Use throttle for scroll, resize and drag. setInterval with a slow async callback overlaps (I counted 4 callbacks in flight). Use a setTimeout that schedules the next one after the work finishes.
Memory leaks from async code
A memory leak is memory your program keeps but can never use again. Async code leaks through things that stay alive and keep references:
- Timers and intervals never cleared. A running timer holds its callback, and everything the callback uses.
- Listeners added per request on something long-lived (a global emitter,
window, a longAbortSignal) and never removed. Node warns after 10 listeners on oneEventEmitter: I sawMaxListenersExceededWarning ... 11 x listeners added. Browsers give no warning. - Promises that never settle, with a big closure attached, in a
Mapthat only grows. - Caches of promises with no limit or expiry.
- Components that set state after they unmount and keep references alive: cancel in the effect cleanup.
- In Node, call
timer.unref()for background timers so they do not keep the process alive (a timer withunref()did not block exit in my test).
Find them with a heap snapshot: take one, use the app, take another, and compare what grew.
Interview questions
(Must know) Explain the event loop, tasks and microtasks in a browser.
Answer. JavaScript has one thread. The loop takes one task, runs it to the end, then runs every microtask until the queue is empty, and then the browser may render (rAF callbacks, style, layout, paint) if a frame is due. Then it takes the next task. Tasks are timers, clicks, network replies and messages. Microtasks are promise callbacks, queueMicrotask and MutationObserver. The key consequence: microtasks can delay painting, and a long task blocks everything. Follow-up: "Where does await go?" The code after await is a microtask.
(Must know) How is Node's event loop different from the browser's?
Answer. Node has no rendering step. It has phases: timers, pending callbacks, poll (I/O), check (setImmediate) and close. Between every callback, it runs process.nextTick callbacks and then microtasks. Node 11 changed this to run between each timer, not between phases. The extra thing to mention is the thread pool (libuv): file system, DNS lookup and crypto work on a pool of 4 threads by default, while network I/O is non-blocking and does not use it. The thread pool is why fs can look parallel while JavaScript is single-threaded. Gotcha: "Does Node run my JavaScript in parallel?" No, only the native work.
(Trap) What does Puzzle 1 print in CommonJS and in ESM? Is setTimeout(0) vs setImmediate fixed?
Answer. After A B, CommonJS prints nextTick, promise, microtask. An ES module prints promise, microtask, nextTick, because the module body itself runs inside a promise job, and the microtask queue empties before the nextTick queue is checked. Then immediate and timeout come in either order from the main script: it depends on whether 1 ms has passed when the loop starts. Inside an I/O callback, immediate always wins. I'd add: in new code I use queueMicrotask, because Node now marks nextTick as Legacy.
(Trap) What does Puzzle 2 print, and why does a1 end come before p2?
Answer. script start, a1 start, a2, p1, script end, a1 end, p2, setTimeout. Code before the first await and the Promise executor run synchronously. await a2() waits for a promise that is already resolved, so the rest of a1 is queued as a microtask at that moment, before .then(p2) was queued. The timer is a task, so it is last. My method: list synchronous lines first, then number each microtask in the order it was queued.
(Must know) then(a, b) vs .then(a).catch(b). And what happens when a handler returns or throws?
Answer. then(a, b): b handles only the promise before it. If a throws, nobody in that call catches it. .then(a).catch(b) also catches errors from a. A handler that returns a value fulfills the next promise, one that throws rejects it, and one that returns a promise makes the next promise follow it. A handler that returns nothing fulfills with undefined, which is the classic "I forgot return" bug in chains. .catch that returns a value recovers the chain. .finally keeps the old result unless it throws.
(Trap) Why does catch not run in try { return fetchData(); } catch {} inside an async function?
Answer. Because return fetchData() hands the promise to the caller. The function leaves the try block before the promise settles, so the rejection reaches the caller, not the catch. With return await fetchData() the function waits inside the try, and catch runs. The cost of await here is a tick or two, which is almost always fine. Some linters even require return await inside try.
(Must know) When do you choose all, allSettled, race and any?
Answer. all when I need every result and one failure should stop everything, like loading a page's required data. allSettled when partial success is acceptable, like sending 20 notifications and reporting which failed. race for a timeout (race the work against a timer), remembering that a rejected promise can also win. any for the first success, like asking three mirrors. Trap follow-ups: Promise.all does not cancel the others, and race([]) never settles, while any([]) rejects with AggregateError. Real cancellation needs an AbortSignal.
(Senior signal) How do unhandled rejections behave in the browser and in Node, and what do you do in production?
Answer. The browser fires an unhandledrejection event on window and logs an error, but the page continues. Node 15+ throws by default: with no handler, the process prints the error and exits with code 1. I add a global handler that logs with context and sends to error tracking. In Node, I let the process exit after logging and rely on the process manager to restart, because the state may be wrong. I also avoid creating a promise and awaiting it later without a handler, since a rejection in the gap counts as unhandled.
(Trap) What is wrong with items.forEach(async (x) => await save(x))? How do you fix it?
Answer. forEach ignores the returned promises, so the code after it runs at once, and errors become unhandled rejections. In my test, "after forEach" printed before any save finished. If order matters, use for...of with await, which is sequential. If saves are independent, use await Promise.all(items.map(save)), which is parallel and keeps result order. If there are many items, use a pool. Related traps: arr.filter(async ...) keeps everything because promises are truthy, and map(async ...) returns promises, not values.
(Senior signal) Write a function that runs N async tasks with at most K at a time.
Answer. I start K workers. Each worker loops: take the next index, run that task, store the result at the same index. Because JavaScript is single-threaded, const i = next++ is safe as long as there is no await between reading and incrementing. I await Promise.all(workers). Then I add a failed flag so that after the first error no worker starts a new task. Without the flag, I measured 3 tasks started before the error and 6 after, so the pool kept working after rejecting. I'd also accept an AbortSignal. In real projects I'd use p-limit or a queue, but I can write it in 10 lines.
(Must know) How do you cancel a request and add a timeout?
Answer. A promise cannot be cancelled, so I cancel the work. I pass AbortSignal.timeout(ms) for a timeout, and an AbortController signal for "user cancelled". AbortSignal.any([...]) joins them. The errors differ: TimeoutError for the timer, AbortError for abort(), or my own reason if I pass one, so I check err.name. I also handle cleanup: { once: true } or a signal on my event listeners. Browser support for any is Chrome 116, Firefox 124 and Safari 17.4. Follow-up: "Does aborting stop the server work?" No, it closes the connection, but the server may still finish unless it watches for that.
(Senior signal) Design a retry with backoff for an API call.
Answer. Retry only errors that can succeed later: network failures, 429 and 5xx, and only if the call is safe to repeat (GET, or a POST with an idempotency key). Wait random(0, min(cap, base * 2^attempt)), which is exponential backoff with full jitter. Without jitter, many clients retry at the same moment and cause a "thundering herd". I honour Retry-After, cap the attempts, and pass an AbortSignal so the wait itself can be cancelled. I do not retry 400, 401 or 404. In my test, it made three calls for two failures, did not retry a 400, and stopped when the signal fired.
(Senior signal) A search box sometimes shows results for an older query. Why, and how do you fix it?
Answer. It is a race condition. Replies can come back in a different order than the requests went out, and the slow old reply overwrites the new one. Fixes, in order of preference: abort the previous request with AbortController (also saves bandwidth); or keep a request counter and apply a result only if it is still the latest; or use a data library such as TanStack Query that keys results by query. Debouncing the input reduces requests but does not fix the bug. In React, the effect cleanup is where I set an ignore flag or call abort().
(Senior signal) A button click freezes the page for 2 seconds. How do you fix it?
Answer. A long task on the main thread blocks painting and input. First, measure it in the Performance panel (long tasks over 50 ms). Then choose: move CPU work to a Web Worker; or split it into chunks and yield between chunks with scheduler.yield() (Chrome and Firefox) or setTimeout as a fallback; or do less work (virtualise a list, memoise). Promise chains do not help: microtasks run before painting, so a long chain starves rendering too. I measured 3 million chained microtasks delaying a 1 ms timer by about 480 ms.
(Trap) Does fetch reject on a 404? What else surprises people?
Answer. No. fetch resolves for any HTTP response, and I must check res.ok or res.status. It rejects only on network failure, a CORS block or an abort (with a TypeError, or an AbortError/TimeoutError). Other surprises: the body can be read only once (second read throws Body is unusable; use clone()), the body is a stream you can read in chunks, there is no built-in timeout, and keepalive bodies are limited to about 64 KiB (my 70 KiB test rejected in Chromium).
(Must know) Debounce vs throttle: when do you use each? Write debounce.
Answer. Debounce waits for a pause: good for search input and autosave. Throttle limits the rate during a stream of events: good for scroll, resize, drag. With events every 30 ms and a 100 ms wait, debounce fired once at the end, and throttle fired about every 100 ms. Debounce is a closure over a timer id: clear it on each call, then set a new setTimeout. I mention the options (leading and trailing edge), a cancel method for unmount, and that for animation requestAnimationFrame throttling is better than a fixed time.
(Senior signal) When would you use a Web Worker, and what is the problem with SharedArrayBuffer?
Answer. I use a worker for CPU-heavy work, like parsing a big file or image processing, and keep the main thread free. Workers do not speed up I/O waits. postMessage copies data, but I can transfer an ArrayBuffer to move it without a copy. SharedArrayBuffer shares memory, but needs cross-origin isolation headers (COOP and COEP) in browsers, and it creates real data races: my four workers doing a plain increment lost about 125,000 of 400,000 counts in one run, while Atomics.add gave the exact result. So I use shared memory only when copying is too slow.
(Senior signal) What can leak memory in async code, and how do you find it?
Answer. Uncleared timers and intervals, listeners added per request on long-lived objects (Node warns at 11 on one emitter), promise caches with no limit, closures held by promises that never settle, and state updates after unmount. I fix it with cleanup: clearTimeout, removeEventListener or { signal }, unref() for background timers, and a bounded cache. To find it, I take heap snapshots before and after a repeated action and compare what grew, or I watch process.memoryUsage() in Node.
(Trap) Explain top-level await and thenables.
Answer. Top-level await works only in ES modules. A .cjs file gives a SyntaxError. A module that awaits makes the modules that import it wait, but not unrelated siblings, so I avoid slow awaits in shared modules. A thenable is any object with a then method. await and Promise.resolve call then in a later microtask, so the order is: before, after, then() called, value. The danger is a plain data object that happens to have a then field: it will be treated as a promise and may hang.
(Gotcha) You click a parent list item and e.target is not the element you expected. What is going on, and what is the cost of one listener per row?
Answer. e.target is the deepest element clicked (maybe a span inside the row). e.currentTarget is the element the listener sits on. With event delegation I put one listener on the list and use e.target.closest("li") to find the row. That saves memory, avoids adding and removing listeners as rows change, and works for rows added later. It does not work for events that do not bubble (focus, mouseenter); use focusin or capture. I'd also mention passive: true for scroll-related listeners, and that stopPropagation breaks delegation higher up the tree.
Key takeaways
- Order of work: synchronous code, then all microtasks, then the next task. Rendering fits between tasks in the browser.
- In Node:
nextTickbefore promises in CommonJS, after them in ES modules.setTimeout(0)vssetImmediateis random from the main script and fixed inside I/O. awaitand.theneach cost a queue step. Do not write code that depends on exact tick counts..then(a, b)does not catch errors froma. Alwaysreturninside.then. Usereturn awaitinsidetry.- Promises cannot be cancelled. Cancel the work with
AbortSignal, and bound every external call withAbortSignal.timeout. - Limit concurrency with a pool, retry only safe errors, and add jitter.
- Protect against stale responses by aborting or checking the latest request.
fetchdoes not reject on HTTP errors.SharedArrayBufferneeds isolation andAtomics.
What's next
Practise by writing each puzzle from memory and predicting the output before you run it. Then read Async JS and the Event Loop for the step-by-step diagrams and Browser Performance for rendering.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- The Event Loop: Timers, Promises and the Poll Phase · Node.js From Zero
Part of the study guide
Related series