JavaScript NotesRohit’s interview study guide
Chapter 07

Async, the Event Loop & Iteration

Promises, async/await, how the event loop orders microtasks and macrotasks, concurrency, generators, workers and animation frames.

19 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Callbacks → Promises → async/await

Added

JavaScript runs your code on one thread: one call stack, one thing at a time. If waiting for a network response blocked that thread, the whole page would freeze — no clicks, no scrolling, no painting — until the response came back. So slow work (network, timers, disk) is handed to the environment (the browser, or libuv in Node), which does the waiting in the background, and your code is told when the result is ready.

How you're told has gone through three generations, each built on the one before. Promises replaced "pass me a function to call later" with an object you get back immediately and can pass around. async/await is syntax on top of promises — under the hood it's still promises.

A useful mental model: a callback is "call me when it's done". A promise is the buzzer you get at a food counter — you get it straight away, before the food exists, and you can hand it to someone else or check it later. await is "stand here until the buzzer goes off" — except only this function stands still; the rest of the program keeps working.

JavaScript
// 1. Callbacks — nesting gets ugly fast ("callback hell")
getUser(id, (err, user) => {
  if (err) return handle(err);
  getOrders(user, (err, orders) => {
    if (err) return handle(err);
    render(orders);
  });
});

// 2. Promises — flat chains, one error handler
getUser(id)
  .then((user) => getOrders(user))
  .then(render)
  .catch(handle);

// 3. async/await — promises that read like synchronous code
try {
  const user = await getUser(id);
  render(await getOrders(user));
} catch (err) {
  handle(err);
}
What’s happening
  1. Callbacks. getUser(id, cb) starts the lookup and returns immediately; later the environment calls cb(err, user). Node's convention is error-first: the first argument is an error or null. To fetch orders you must nest the next call inside the first callback, because that's the only place user exists. Every extra step adds a level of indentation, and every level must repeat if (err) return handle(err) — forget one and that error silently disappears.
  2. Promises. getUser(id) now returns a promise straight away. The first .then callback returns the promise from getOrders(user), so the chain waits for it and hands the orders to render. The chain stays flat however many steps you add, and a rejection at any step skips the remaining .thens and lands in the single .catch(handle).
  3. async/await. await getUser(id) pauses this function until the promise settles, then gives you the fulfilled value — or, if it rejected, throws the reason as an exception. That's why one ordinary try/catch covers errors from both calls. render(await getOrders(user)) waits for the orders, then calls render with them.
  4. All three do the same work in the same order, and none of them blocks the thread — only the way you write "and then" changes. await is only allowed inside an async function or at the top level of an ES module, so the third snippet assumes one of those.
AdvancedWhere each async style belongs

Where each style still belongs:

  • Callbacks are still right for things that happen many times — addEventListener, setInterval, stream data events. A promise represents a single future value and can settle only once.
  • Old callback APIs can be turned into promises with util.promisify in Node, or by wrapping them in new Promise yourself. Most Node core modules already ship a promise version (node:fs/promises, node:timers/promises).
  • async/await is the default for sequential async logic in modern code; plain .then is still handy for a one-off "do this when it's ready" without making the surrounding function async.

#Promises

A Promise is an object standing in for a value that will be available later. It's always in one of three states: pending, fulfilled (with a value) or rejected (with a reason). It starts pending and settles at most once — after that, its state and value never change, and further attempts to resolve or reject it are ignored.

Three properties make promises useful:

  • You get the promise immediately, before the result exists, so you can return it, store it, or pass it to other code.
  • Handlers attached with .then run once it settles — even if you attach them after it settled. Unlike an event, you can't "miss" a promise.
  • Every .then, .catch and .finally returns a new promise. That's what makes chaining work. The new promise settles based on what the handler does: return a value → fulfilled with that value; return a promise → follows that promise; throw → rejected with the thrown error.
JavaScript
function returnAfter(ms, value) {
  return new Promise((resolve, reject) => {
    if (ms < 0) return reject(new RangeError("ms must be ≥ 0"));
    setTimeout(() => resolve(value), ms);
  });
}

returnAfter(1000, 42)
  .then((v) => v * 2)          // return a value → next then gets it
  .then((v) => returnAfter(500, v + 1)) // return a promise → chain waits for it
  .then(console.log)           // 85
  .catch(console.error)        // catches a rejection from ANY step above
  .finally(() => console.log("done")); // runs either way, receives nothing
What’s happening
  1. returnAfter(1000, 42) calls new Promise(executor). The executor runs right away: ms isn't negative, so it starts a 1-second timer and returns. The promise — call it p1 — is pending.
  2. The whole chain is built synchronously, at t = 0. Each .then, .catch and .finally registers a handler on the promise before it and returns a new pending promise — six pending promises in a row. None of the callbacks has run yet.
  3. At ~1000 ms the timer fires (a macrotask) and calls resolve(42). p1 becomes fulfilled with 42, which queues the first handler as a microtask. It runs v * 2 and returns 84, so the second promise fulfils with 84.
  4. The next handler returns returnAfter(500, 85) — a promise, not a value. The third promise doesn't fulfil with a promise object; it locks onto the returned promise and stays pending until that one settles. This is how a chain waits for nested async work.
  5. At ~1500 ms the inner timer fires, the inner promise fulfils with 85, the third promise follows it, and .then(console.log) logs 85. console.log returns undefined, so the fourth promise fulfils with undefined.
  6. .catch only reacts to rejections. Nothing rejected, so its handler is skipped and the value passes through. .finally runs on either outcome, logs "done", receives no argument, and passes the previous result along unchanged.
  7. If you called returnAfter(-1, 42) instead, the executor would reject immediately with a RangeError. Each .then passes a rejection through without running its callback, so .catch(console.error) logs the RangeError — and "done" still prints.
AdvancedPitfall: unreturned promises in .then
AdvancedAvoiding nesting and wrapped promises

Two more habits keep promise code clean:

  • Don't nest .then inside .then. Return the inner promise and continue the chain at the outer level; nesting brings callback hell back.
  • Don't wrap a promise in new Promise. If a function already returns a promise, return it (or .then it) directly. Wrapping it means you have to forward rejections by hand, and it's easy to lose one.

#The Promise executor runs synchronously

The function you pass to new Promise(...) is called the executor, and it runs immediately, right there in the current call stack. Only the .then callbacks are asynchronous.

That makes sense once you see what the executor is for: it starts the async work — sets the timer, sends the request — and that should happen now, not later. The constructor's contract is "start the job now and give me the buzzer"; .then is "when the buzzer goes off, do this — but never before the code that's currently running has finished".

JavaScript
console.log("1");
const p = new Promise((resolve) => {
  console.log("2 — executor runs now");
  resolve();
  console.log("3 — still runs after resolve()");
});
p.then(() => console.log("5 — then is a microtask"));
console.log("4");

// 1, 2, 3, 4, 5
What’s happening
  1. "1" logs. new Promise(...) calls the executor on the current call stack (the stack is: script → Promise constructor → executor), so "2" logs straight away.
  2. resolve() moves p from pending to fulfilled with the value undefined. No handlers are attached yet, so nothing is queued. resolve is just a function call — it doesn't return or throw — so the executor carries on and logs "3".
  3. The constructor returns p, already fulfilled. p.then(...) attaches a handler to a settled promise; it does not run the handler now — it queues it as a microtask. Microtask queue: [log "5"].
  4. "4" logs. The script ends, the call stack is empty, the event loop drains the microtask queue, and "5" logs.
  5. The point: even though p was fulfilled before .then was called, the callback still waited. A .then callback never runs synchronously, so code written after .then always runs first — whether the promise was already settled or not. That consistency is a deliberate design guarantee.
AdvancedCode after resolve() still runs

Also note: calling resolve() doesn't stop the executor — code after it still runs. Use return resolve(x) if you want to exit (that's what returnAfter above does with return reject(...)). Two related rules:

JavaScript
const once = new Promise((resolve, reject) => {
  resolve("first");
  reject(new Error("ignored")); // already settled — no effect
  resolve("also ignored");
});
once.then(console.log); // "first"

const boom = new Promise(() => {
  throw new Error("boom"); // a throw inside the executor = reject(error)
});
boom.catch((e) => console.log(e.message)); // "boom"
What’s happening
  1. In once, the first resolve("first") settles the promise. The later reject and resolve calls are silently ignored — a promise settles once. They don't throw, so bugs like this are easy to miss.
  2. In boom, the executor throws. The constructor catches the exception and rejects the promise with it instead of letting it escape, so new Promise itself never throws here and .catch receives the error.
  3. A throw after resolve() has already been called is swallowed, for the same reason as step 1: the promise is already settled.

#async/await

An async function is a function that always returns a promise and is allowed to use await inside. await somePromise pauses this function until the promise settles: if it fulfils, await evaluates to the value; if it rejects, await throws the reason, so you can use ordinary try/catch.

The mental model is a bookmark. At an await, the engine saves the function's position and its local variables, takes the function off the call stack, and returns to whoever called it. The thread is free to do other work. When the awaited promise settles, a microtask puts the function back on the stack exactly where the bookmark was. Nothing blocks — await is not sleep.

JavaScript
function returnAfter25secs() {
  return new Promise((resolve) => setTimeout(() => resolve(1), 25_000));
}

async function callThis() {
  const result = await returnAfter25secs(); // pauses THIS function only
  console.log(result); // 1, after 25 seconds
}

callThis();
console.log("this logs first — the rest of the program keeps running");
What’s happening
  1. callThis() is called and its body starts running synchronously. It calls returnAfter25secs(), which creates a promise and starts a 25-second timer. That promise is pending.
  2. await sees a pending promise, so callThis suspends: its position and the (not yet assigned) result are saved, and the call returns to the caller with a pending promise of its own (ignored here).
  3. The caller carries on, and "this logs first…" prints immediately. Then the call stack is empty, and for the next 25 seconds the thread is free to handle clicks, timers, anything else.
  4. At 25 s the timer's macrotask calls resolve(1). That queues the rest of callThis as a microtask. It runs, await evaluates to 1, result becomes 1, and 1 is logged. callThis's own promise then fulfils with undefined.
  5. "Pauses THIS function only" is the whole point: await splits the function into a "before" part and an "after" part, and everything else keeps running in between.
AdvancedCatching errors with try/catch

Errors become exceptions you can try/catch:

JavaScript
async function fetchData() {
  try {
    const res = await fetch("/api/user");
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch doesn't reject on 404/500!
    return await res.json();
  } catch (error) {
    console.error(error);
    return null;
  }
}
What’s happening
  1. await fetch("/api/user") pauses until the response headers arrive. If the request can't be made at all (offline, DNS failure, CORS block), the promise rejects with a TypeError; await throws it, and control jumps to catch.
  2. A 404 or 500 is not a network failure — the server did answer — so fetch fulfils with a Response whose ok is false. Without the if (!res.ok) line you'd carry on and try to parse an error page. Throwing here turns the bad status into an exception that lands in the same catch.
  3. res.json() downloads the body and parses it. That's async too, and it rejects with a SyntaxError if the body isn't valid JSON (an HTML error page, say).
  4. The await in return await res.json() matters because it's inside try. It makes a rejection from res.json() throw inside the try, so the catch handles it. With plain return res.json(), the function would hand back the promise before it settled, and the SyntaxError would escape to the caller instead. Outside a try, return await x and return x behave the same.
  5. The catch logs and returns null, so fetchData() always fulfils — with the data or with null. Callers never need their own try/catch, but they do have to handle null.
Advancedfetch and HTTP error statuses
AdvancedPractical rules for await

Practical rules:

  • Only try/catch around an await when you can do something useful there — a fallback value, a retry, or wrapping the error with context (see Error.cause). Otherwise let the rejection propagate to a caller that can deal with it.
  • await inside a loop runs the iterations one after another. That's correct when each step depends on the last; when they're independent, start them together (see Promise combinators).
  • array.forEach(async (x) => { await save(x); }) does not wait: forEach ignores the promises its callback returns, so it finishes before any save has completed, and any rejections go unhandled. Use for...of with await (sequential) or await Promise.all(array.map(save)) (concurrent).

#Async functions always return a promise

Whatever you return from an async function is wrapped in a promise; whatever you throw becomes a rejection. That's true even for errors thrown on the very first line, before any await: an async function never throws synchronously to its caller. And if you return a promise, the function's promise simply follows it — you never get a promise of a promise.

JavaScript
async function one() {
  return 1;
}
one();                 // Promise {<fulfilled>: 1}, not 1
one().then(console.log); // 1

async function fail() {
  throw new Error("nope");
}
fail().catch((e) => console.log(e.message)); // "nope" — no synchronous throw
What’s happening
  1. one() runs its body synchronously. There's no await, so it reaches return 1 at once; the engine fulfils the function's promise with 1 and returns the promise. Chrome's console shows Promise {<fulfilled>: 1}; Node prints Promise { 1 }.
  2. one().then(console.log) calls one again and attaches a handler. The promise is already fulfilled, but the handler still runs as a microtask, after the current synchronous code — the rule from the previous topic.
  3. fail() throws on its first line. A normal function would throw into the caller; an async function catches the exception and returns a rejected promise instead. Wrapping the call in try { fail(); } catch {} would catch nothing.
  4. .catch((e) => console.log(e.message)) logs "nope" as a microtask. Without the .catch (or an await inside a try), it would be an unhandled rejection.
AdvancedWhy async is contagious

That's why you can't get a value out of an async function synchronously — callers have to await it or use .then. Async is "contagious": a function that needs the result must itself become async (or use .then), and so must its caller, up to some top-level entry point. Top-level await (ES2022) is allowed in ES modules: the module's evaluation pauses, and any module that imports it waits until it finishes.

#await continuations are microtasks

When an async function hits await, it pauses and returns to its caller. The rest of the function (the continuation) is scheduled as a microtask once the awaited promise settles — even if the value was already available.

Think of the engine cutting the function into pieces at every await. The first piece runs synchronously as part of the call. Each later piece is queued as a microtask when the value it waits for is ready. If you await something that isn't a promise (like null or 5), it's wrapped in an already-fulfilled promise, so the next piece is queued immediately — but it still has to wait its turn in the queue. await always gives up the thread at least once.

JavaScript
async function demo() {
  console.log("B");
  await null;           // even awaiting a non-promise yields
  console.log("D");
}

console.log("A");
demo();
console.log("C");
// A, B, C, D
What’s happening
  1. "A" logs. demo() is called and pushed onto the call stack; "B" logs — this is still synchronous, part of the call.
  2. await null wraps null in a fulfilled promise, so the continuation (console.log("D")) is queued straight away. Microtask queue: [demo, after await]. demo suspends and returns a pending promise; the stack is back to just the script.
  3. "C" logs. The script ends and the call stack is empty.
  4. The event loop drains the microtask queue: demo resumes after its await, logs "D", reaches the end, and its promise fulfils with undefined.
AdvancedTwo async functions taking turns

Everything before the first await runs synchronously as part of the call. When two async functions are in flight, they take turns at every await:

JavaScript
async function first() {
  console.log("first: 1");
  await null;
  console.log("first: 2");
  await null;
  console.log("first: 3");
}

async function second() {
  console.log("second: 1");
  await null;
  console.log("second: 2");
}

first();
second();
console.log("sync code done");
// first: 1, second: 1, sync code done, first: 2, second: 2, first: 3
What’s happening
  1. first() runs synchronously to its first await: logs first: 1 and queues its continuation. Microtasks: [first#2].
  2. second() does the same: logs second: 1. Microtasks: [first#2, second#2].
  3. sync code done logs; the stack empties and draining begins.
  4. first#2 runs: logs first: 2, hits its second await, and queues first#3 at the back of the queue. Microtasks: [second#2, first#3].
  5. second#2 logs second: 2 and finishes. Then first#3 logs first: 3.
  6. Each await sends the rest of the function to the back of the line, so async functions interleave like cooperative multitasking. Between two awaits, though, a function runs uninterrupted — no other JavaScript can sneak in. That guarantee is what makes the next++ in Limiting concurrency safe.

#The event loop

The event loop is how a single-threaded runtime handles asynchronous work. JavaScript has run-to-completion semantics: once a piece of code starts (a script, a callback, a microtask), nothing interrupts it until it returns. Asynchronous callbacks never pre-empt running code; they wait in queues until the call stack is empty.

A mental model: one chef (the thread) works at one station (the call stack). Helpers (Web APIs / libuv) do all the waiting — for the oven timer, for deliveries. When something is ready, a ticket goes into one of two trays. The chef only checks the trays after finishing the current dish. They always empty the priority tray (microtasks) completely — including tickets added while emptying it — and only then take one ticket from the normal tray (macrotasks). In a browser, the dining room can be repainted between normal tickets.

  1. The engine runs synchronous code on the call stack. The initial script is itself the first macrotask.
  2. Async operations (setTimeout, fetch, DOM events, file I/O) are handed to the Web APIs (browser) or libuv (Node), which do the work in the background — on other threads or in the operating system, not on the JavaScript thread.
  3. When the work finishes, a callback is queued. Timers, I/O and UI events queue a macrotask (task). When a promise settles, its .then/.catch/.finally reactions and await continuations are queued as microtasks.
  4. Whenever the call stack is empty, the event loop drains the entire microtask queue (including microtasks added while draining).
  5. Then it takes one macrotask, runs it, and goes back to step 4. In browsers, rendering can happen between macrotasks.
Call stack console.log() handleClick() main() Web APIs / libuv setTimeout · fetch DOM events · file I/O Event loop ① async work handed off ② callback queued Microtasks drain ALL promise.then await … queueMicrotask Macrotasks ONE at a time setTimeout cb click handler I/O callback ③ when stack is empty
Microtasks always jump ahead of the next macrotask.

Here are steps 4 and 5 in action — note where the microtask queued by a timer runs:

JavaScript
setTimeout(() => console.log("timeout 1"), 0);
setTimeout(() => {
  console.log("timeout 2");
  Promise.resolve().then(() => console.log("microtask from timeout 2"));
}, 0);
setTimeout(() => console.log("timeout 3"), 0);

Promise.resolve().then(() => {
  console.log("microtask 1");
  queueMicrotask(() => console.log("microtask 2 (queued while draining)"));
});
console.log("script done");

// script done, microtask 1, microtask 2 (queued while draining),
// timeout 1, timeout 2, microtask from timeout 2, timeout 3
What’s happening
  1. Synchronous pass. Three setTimeout calls hand three timers to the host; as each expires, its callback joins the macrotask queue in registration order: [timeout 1, timeout 2, timeout 3]. Promise.resolve().then(...) attaches to an already-fulfilled promise, so its callback goes straight into the microtask queue: [microtask 1]. "script done" logs.
  2. The script (the first macrotask) is finished and the stack is empty, so the loop drains microtasks. microtask 1 runs and, while running, queues another: microtasks [microtask 2]. Draining means "until the queue is empty", so microtask 2 runs too — before any timer.
  3. The microtask queue is empty, so the loop takes one macrotask: timeout 1 logs. It queued nothing, the microtask queue is still empty, so the loop takes the next macrotask.
  4. timeout 2 logs and queues a microtask. Before touching timeout 3, the loop drains microtasks again, so microtask from timeout 2 logs between the two timers.
  5. timeout 3 logs. The lesson: the microtask queue is drained after every macrotask, not once per round. (Node before v11 ran all expired timers back to back first; since Node 11 it matches browsers.)
AdvancedsetTimeout(fn, 0) and run-to-completion

Practical consequences:

  • setTimeout(fn, 0) means "at least 0 ms, then wait your turn behind everything already queued". Node treats 0 as 1 ms; browsers clamp deeply nested timers to at least 4 ms and throttle timers in background tabs.
  • Because of run-to-completion, a long synchronous loop blocks everything — no timers, no clicks, no rendering — until it finishes.

#Microtasks vs macrotasks

Why two queues? Microtasks are for "finish this piece of work before anything else happens". When a promise settles, the code reacting to it should run as soon as the current code finishes, while the world is still in the state it left it — before a timer, a click handler or a repaint can see a half-finished update. Macrotasks are separate, independent units of work arriving from outside: a timer firing, a user click, a network message.

Microtasks (higher priority)Macrotasks / tasks
promise.then / catch / finallysetTimeout, setInterval
await continuationsDOM events (click, scroll, input)
queueMicrotask(fn)Network events (XHR load, the response that settles a fetch), I/O
MutationObserver callbacksMessageChannel, postMessage
Node: process.nextTick (runs even before promises)Node: setImmediate

Microtasks always run before the next macrotask. A setTimeout(fn, 0) is "as soon as possible after the current task and all microtasks" — not immediately.

One subtlety in the table: with fetch(url).then(cb), the network response arrives as a task, and that task fulfils the promise — but cb itself runs as a microtask straight after it. An XHR onload handler, by contrast, is the task.

The process.nextTick row also needs a caveat, because the order depends on the module system:

JavaScript
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
// CommonJS (.js / .cjs): nextTick, promise
// ES module (.mjs):      promise, nextTick
What’s happening
  1. Both callbacks are queued during the synchronous script; neither runs until the script finishes.
  2. In CommonJS, the script runs as a plain macrotask. When it ends, Node processes its nextTick queue first and then the promise microtask queue: nextTick, promise.
  3. In an ES module, the module body is itself evaluated from inside a promise job. When it ends, V8 is still draining promise jobs, so promise runs first, and Node only gets to its nextTick queue afterwards: promise, nextTick.
  4. Takeaway: don't build logic on the relative order of nextTick and promises. Use queueMicrotask (standard, works in browsers too); process.nextTick is mostly for Node internals and libraries that must run before any promise callback.
Predict the output (warm-up)
JavaScript
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Show answer

1, 4, 3, 2 — synchronous logs first, then the microtask (promise), then the macrotask (timeout).

What’s happening
  1. console.log("1") → 1. Microtasks [], macrotasks [].
  2. setTimeout(..., 0) hands a timer to the host and returns at once. When it expires, its callback joins the macrotask queue: macrotasks [log 2].
  3. Promise.resolve() creates an already-fulfilled promise, so .then queues its callback immediately: microtasks [log 3].
  4. console.log("4") → 4. The script ends; the call stack is empty.
  5. Drain microtasks → 3. Microtasks [].
  6. Take one macrotask → 2. Even with a 0 ms delay, the timer had to wait for the whole script and every microtask.
Predict the output (interview level)
JavaScript
console.log("A");

setTimeout(() => console.log("B"), 0);

new Promise((resolve) => {
  console.log("C");
  resolve();
}).then(() => console.log("D"));

(async () => {
  console.log("E");
  await null;
  console.log("F");
})();

queueMicrotask(() => console.log("G"));

console.log("H");
Show answer

A, C, E, H, D, F, G, B — four synchronous logs, then three microtasks in the order they were queued, then the one macrotask.

What’s happening
  1. A logs. Microtasks [], macrotasks [].
  2. setTimeout registers a 0 ms timer; when it expires, its callback joins the macrotask queue. Macrotasks [B].
  3. new Promise runs the executor synchronously → C. resolve() fulfils the promise. .then(D) is attached to an already-fulfilled promise, so D is queued immediately. Microtasks [D].
  4. The async IIFE runs synchronously up to its first await → E. await null queues the continuation, and the IIFE suspends, returning a pending promise. Microtasks [D, F].
  5. queueMicrotask queues G: microtasks [D, F, G]. Then H logs. The script ends; the call stack is empty.
  6. Drain microtasks in FIFO order: D (its .then promise fulfils, nobody is listening), F (the IIFE resumes and finishes; its promise fulfils), G. None of them queues anything new. Microtasks [].
  7. Take one macrotask → B. Both queues are now empty.
  8. The usual trap is D vs F: both are microtasks, so they run in the order they were queued, and D was queued first. If the executor called resolve from inside a setTimeout instead, D would only be queued when that later timer fired — and the output would end F, G, B, D.
AdvancedPitfall: endless microtask chains

#Promise combinators for concurrent work

These static methods take an iterable of promises and return one promise that settles based on them. They don't start anything: the work starts the moment you call the functions that create the promises (fetch(...)). The combinator only decides how to combine the results. And none of them cancels the losers — a promise that's no longer needed keeps running; its result is just ignored.

MethodResolves whenRejects whenUse for
Promise.all(ps)All fulfil → array of valuesAny rejects (fail-fast)Independent requests you need together
Promise.allSettled(ps)All settle → array of {status, value | reason}NeverBatch jobs where some may fail
Promise.race(ps)The first to settle (either way)The first to settle rejectsTimeouts
Promise.any(ps)The first to fulfilAll reject → AggregateErrorFastest mirror / fallback sources
JavaScript
// Parallel — total time ≈ the slowest request
const [user, posts] = await Promise.all([
  fetch("/api/user").then((r) => r.json()),
  fetch("/api/posts").then((r) => r.json()),
]);

// Keep going even if some fail
const results = await Promise.allSettled(urls.map((u) => fetch(u)));
const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);

// Timeout with race
const timeout = (ms) =>
  new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out")), ms));
const data = await Promise.race([fetch("/slow"), timeout(5000)]);

// First successful mirror
const fastest = await Promise.any([fetch(mirrorA), fetch(mirrorB)]);
What’s happening
  1. Promise.all. Building the array literal calls both fetches, so both requests are in flight before Promise.all even sees them. It fulfils when both .json() promises have fulfilled, with the values in input order (not completion order), which destructure into user and posts. If either rejects, Promise.all rejects at once with that reason; the other request isn't cancelled.
  2. Promise.allSettled. urls.map(...) starts every request at once. It waits until all have settled and never rejects; each entry is { status: "fulfilled", value } or { status: "rejected", reason }, and the filter/map keeps the successful Responses. Remember that fetch only rejects on network failure, so a 404 still counts as "fulfilled" here — check value.ok if you care.
  3. Promise.race with a timeout. timeout(5000) returns a promise that can only reject. If the response arrives within 5 s, data is the Response; otherwise await throws "Timed out". The slow request keeps going in the background — to actually stop it, pass an abort signal (see Cancelling with AbortController).
  4. Promise.any. The first fulfilment wins; a rejection from mirrorA is ignored as long as mirrorB succeeds. Only if every promise rejects do you get an AggregateError, whose .errors array holds each reason.
  5. Empty input is an edge case interviewers like: Promise.all([]) and Promise.allSettled([]) fulfil with [], Promise.any([]) rejects with AggregateError, and Promise.race([]) stays pending forever.
AdvancedPitfall: awaiting independent calls in turn

#Limiting concurrency

Added

Promise.all(items.map(fetchItem)) fires every request at once — 1,000 items means 1,000 simultaneous requests. That's a problem: servers rate-limit you, browsers only open a handful of connections per host anyway (about 6 over HTTP/1.1), and every in-flight request holds memory.

A small worker pool caps it: start limit workers that share one list of items, and let each worker take the next unclaimed item as soon as it finishes its current one — like a few cashiers serving a single queue of customers.

mapWithLimit.js
async function mapWithLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;

  async function worker() {
    while (next < items.length) {
      const i = next++;            // safe: JS is single-threaded between awaits
      results[i] = await fn(items[i], i);
    }
  }

  const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
  await Promise.all(workers);
  return results; // same order as items
}

const pages = await mapWithLimit(urls, 5, (url) => fetch(url).then((r) => r.text()));
What’s happening
  1. results is pre-sized so each answer can go into its own slot, results[i]. That's what keeps the output in input order even though tasks finish in any order. next is the shared index of the next item nobody has claimed yet.
  2. Array.from({ length: Math.min(limit, items.length) }, worker) calls worker up to limit times (Math.min avoids idle workers when there are fewer items than the limit). Each call runs synchronously until its first await — claiming an index and starting fn — and returns a pending promise. With limit = 5, items 0–4 start straight away.
  3. const i = next++ is safe because there's no await between the while check and the increment: once a worker starts that line, no other worker can run until it reaches await. Two workers can never claim the same index.
  4. When a task finishes, its worker resumes, stores the result, loops, and claims the next index. So at most limit tasks are in flight, and a free slot is refilled immediately rather than waiting for a whole batch.
  5. When next reaches items.length, each worker's while exits and its promise fulfils. Promise.all(workers) waits for the last worker, then results is returned.
  6. If fn rejects, that worker's promise rejects and Promise.all rejects mapWithLimit — but the other workers keep processing the remaining items. To collect failures instead, catch inside fn and return an error marker.
AdvancedWatching the worker pool run

To see the pool working, give it fake tasks with known durations and a limit of 2:

JavaScript
const delays = [300, 100, 200, 100, 100];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const out = await mapWithLimit(["a", "b", "c", "d", "e"], 2, async (x, i) => {
  console.log("start", x);
  await wait(delays[i]);
  return x.toUpperCase();
});
console.log(out); // ["A", "B", "C", "D", "E"], after ~400 ms
What’s happening
  1. t = 0: worker 1 claims a (300 ms) and worker 2 claims b (100 ms). next is 2. Logs start a, start b.
  2. t = 100: b finishes. Worker 2 stores results[1] = "B" and claims c (200 ms). Logs start c.
  3. t = 300: a finishes, so worker 1 stores "A" and claims d. c also finishes at 300 ms, so worker 2 claims e. next is now 5. Logs start d, start e.
  4. t = 400: d and e finish. Both workers see next === 5, exit their loops, and Promise.all fulfils. Total ≈ 400 ms, compared with 800 ms one at a time and 300 ms with no limit.
  5. out is in input order even though b finished first, because every result went into results[i]. In production, libraries such as p-limit and p-map implement the same idea.

#Cancelling with AbortController

Added

A promise has no "cancel" button — it's a read-only receipt for work that's already happening. AbortController adds cancellation by splitting it into two objects: the controller, which has the power to cancel (abort()), and its signal, which you hand to the operations that should listen. Many APIs accept a signal: fetch, addEventListener (to remove listeners), and many Node APIs (fs.readFile, timers/promises, streams). One abort() call can cancel all of them at once.

JavaScript
const controller = new AbortController();

fetch("/api/search?q=js", { signal: controller.signal })
  .then((r) => r.json())
  .catch((err) => {
    if (err.name === "AbortError") return; // we cancelled it — not a real error
    throw err;
  });

controller.abort(); // e.g. the user typed again, or the component unmounted

// Built-in timeout
await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });
What’s happening
  1. new AbortController() creates a controller whose .signal is an AbortSignal with aborted === false. Passing that signal to fetch makes the request listen to it.
  2. fetch starts the request and returns a pending promise; .then and .catch are attached. Nothing has come back yet.
  3. controller.abort() runs synchronously on the next line: signal.aborted becomes true, and fetch is notified. It cancels the request and rejects its promise with a DOMException named "AbortError". (If the response had already arrived, the body read is aborted instead, so r.json() rejects the same way.)
  4. The rejection skips .then((r) => r.json()) and reaches .catch. err.name === "AbortError", so the handler returns undefined and the chain ends quietly. Anything else is re-thrown, so genuine failures aren't swallowed.
  5. AbortSignal.timeout(5000) creates a signal that aborts itself after 5 s. Careful: that rejects with a DOMException named "TimeoutError", not "AbortError", so the check above would not treat a timeout as a deliberate cancel — usually what you want. Similarly, controller.abort(reason) makes fetch reject with your reason instead of an AbortError.
  6. The last line uses top-level await, so it needs an ES module (or an async function).
AdvancedAborting stale debounced searches

Pairs perfectly with debounced search: abort the previous request whenever a new one starts, so stale responses can't overwrite fresh ones.

JavaScript
let controller;

async function search(q) {
  controller?.abort(); // cancel the previous request, if any
  controller = new AbortController();
  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal });
    renderResults(await res.json());
  } catch (err) {
    if (err.name !== "AbortError") throw err;
  }
}
What’s happening
  1. controller lives outside the function, so each call can reach the controller of the call before it.
  2. The user types j: search("j") creates controller A, starts request A, and suspends at await fetch(...).
  3. They type s before A returns: search("js") calls controller.abort() on A. Request A rejects with AbortError; the first call resumes inside its catch, sees "AbortError", and exits quietly. Then controller B is created and request B starts.
  4. Only B's response ever reaches renderResults. Without the abort, a slow response for "j" could arrive after the response for "js" and overwrite the newer results — a classic out-of-order race condition.

#Error.cause

ES2022 lets you wrap a low-level error in a more meaningful one without losing the original.

Before this, you had two bad options when catching and re-throwing: throw a new error and lose the original message and stack, or glue messages together into one long string. With new Error(message, { cause }), the new error describes what you were trying to do, and its .cause property holds the original error that explains why it failed. Think of it as a chain of "because": "Failed to load user 7" because "Unexpected token '<'". It works with every built-in error type and with subclasses that pass the options object to super.

JavaScript
async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  } catch (err) {
    throw new Error(`Failed to load user ${id}`, { cause: err });
  }
}

try {
  await loadUser(7);
} catch (err) {
  console.log(err.message);      // "Failed to load user 7"
  console.log(err.cause);        // the original TypeError / SyntaxError
}
What’s happening
  1. Two things can go wrong inside loadUser: the network fails, so fetch rejects with a TypeError; or the body isn't JSON (an HTML error page, say), so res.json() rejects with a SyntaxError. Both are thrown inside the try because of the awaits.
  2. catch (err) receives the low-level error. It tells you what broke but not what the app was doing — "Unexpected token '<'" says nothing about users.
  3. throw new Error(..., { cause: err }) creates a higher-level error and stores the original as its cause (an own, non-enumerable property). Because loadUser is async, this throw rejects its promise.
  4. The caller's await turns that rejection back into an exception in its catch: err.message is the friendly message and err.cause is the original error with its own message and stack. Node's console.log(err) prints the whole chain, marking the original with [cause].
  5. Note that loadUser doesn't check res.ok, so a 404 whose body happens to be JSON would be returned as if it were the user. Real code would add if (!res.ok) throw new Error("HTTP " + res.status) inside the try — and that error would get wrapped with a cause too.

#Iterators and the iteration protocol

Two small contracts power for...of, spread, destructuring, Array.from, Promise.all and more:

  • An iterator is an object with a next() method that returns { value, done }.
  • An iterable is an object with a [Symbol.iterator]() method that returns an iterator.

Why bother with a protocol? Because it gives arrays, strings, Maps, Sets, DOM collections and your own classes one uniform way to say "here is the next item". for...of and spread don't need to know what kind of collection they're looping over — they just follow the protocol. Iteration is pull-based: the consumer calls next() when it wants another value, so the producer can compute values lazily. Picture a ticket dispenser: the iterable is the machine you can ask for a fresh roll; the iterator is the roll, handing out one ticket at a time.

JavaScript
const arr = ["a", "b"];
const it = arr[Symbol.iterator]();
it.next(); // { value: "a", done: false }
it.next(); // { value: "b", done: false }
it.next(); // { value: undefined, done: true }
What’s happening
  1. arr[Symbol.iterator]() asks the array for a fresh iterator. Symbol.iterator is a built-in symbol used as a method name, so it can never clash with an ordinary string key.
  2. Each next() returns the next element wrapped as { value, done: false }. The iterator remembers its own position; the array itself isn't changed.
  3. The third call has run past the end, so it returns { value: undefined, done: true }. Every call after that keeps returning done: true.
  4. for...of does exactly this for you: it gets an iterator, calls next() until done is true, and hands you each value. Plain objects don't have [Symbol.iterator], which is why for (const x of {}) throws TypeError: {} is not iterable.
AdvancedCustom iterable: a Range class

Custom iterable

JavaScript
class Range {
  constructor(start, end, step = 1) {
    Object.assign(this, { start, end, step });
  }

  [Symbol.iterator]() {
    let current = this.start;
    const { end, step } = this;
    return {
      next() {
        if (current > end) return { value: undefined, done: true };
        const value = current;
        current += step;
        return { value, done: false };
      },
    };
  }
}

const r = new Range(1, 10, 3);
[...r];                    // [1, 4, 7, 10]
for (const n of r) console.log(n);
const [first, second] = r; // 1, 4
What’s happening
  1. new Range(1, 10, 3) just stores start, end and step. No values are computed and no array is built.
  2. [...r] calls r[Symbol.iterator](), which creates current = 1 in a closure and returns an object with next(). Spread calls next() repeatedly: it returns 1 (current → 4), 4 (→ 7), 7 (→ 10), 10 (→ 13), and then 13 > 10 gives done: true. Result: [1, 4, 7, 10].
  3. for (const n of r) calls [Symbol.iterator]() again and gets a brand-new iterator with its own current = 1, so it logs 1, 4, 7, 10 from the start. Because the position lives in the iterator, not on the Range, the range can be looped over any number of times, even by two loops at once.
  4. const [first, second] = r gets a third fresh iterator, calls next() twice, and stops — 7 and 10 are never computed. (Destructuring that stops early calls the iterator's return() method, if it has one, so it can clean up.)
  5. The object returned by [Symbol.iterator]() here is an iterator but not itself iterable — it has no [Symbol.iterator] of its own — so you can't for...of over it directly. Built-in iterators and generators are both, which is the next topic.

#Generators

A generator function (function*) can pause at each yield and resume later. Calling it returns a generator object, which is both an iterator and an iterable — so generators are the easiest way to write custom iterables.

The key difference from a normal function: calling a generator function doesn't run its body. It returns a paused generator object. Each call to next() runs the body until the next yield, hands out that value, and freezes again with all its local variables intact — like pausing a video, not stopping it. Generators exist because hand-written iterators (like Range above) are really little state machines; a generator lets you write the same logic as straight-line code with loops, and the language keeps track of where you were.

JavaScript
function* idGenerator() {
  let id = 1;
  while (true) {
    yield id++; // pause here, hand out a value
  }
}

const ids = idGenerator();
ids.next().value; // 1
ids.next().value; // 2 — infinite, but lazy: values are made only on demand

// The Range class again, much shorter
class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }
  *[Symbol.iterator]() {
    for (let n = this.start; n <= this.end; n++) yield n;
  }
}
[...new Range(1, 5)]; // [1, 2, 3, 4, 5]
What’s happening
  1. idGenerator() runs none of the body. It returns a generator object paused at the very start; id doesn't exist yet.
  2. First ids.next(): the body starts, let id = 1, enters while (true), and evaluates id++ — the expression gives 1 and id becomes 2. yield 1 pauses the function and next() returns { value: 1, done: false }.
  3. Second ids.next(): execution resumes just after the yield, loops round, id++ gives 2 (id becomes 3), and yield 2 pauses again → { value: 2, done: false }.
  4. while (true) is harmless because the generator only runs when asked; between next() calls it's frozen and uses no CPU. But anything that consumes the whole thing — [...idGenerator()], Array.from(idGenerator()) — would never finish.
  5. In the new Range, *[Symbol.iterator]() is a generator method. Each call returns a fresh generator, which is an iterator, so Range satisfies the iterable protocol. Spread pulls 1 to 5; when n becomes 6, the loop ends, the function returns, and next() gives { value: undefined, done: true }.
  6. Compare it with the hand-written version: current, the { value, done } objects and the end check have all disappeared — the generator machinery produces them for you.
AdvancedTwo-way generators with next(x)

Generators can also receive values: gen.next(x) makes the paused yield expression evaluate to x. That two-way channel is how libraries like redux-saga (and, historically, co before async/await) work.

JavaScript
function* conversation() {
  const name = yield "What's your name?";
  yield `Hello, ${name}!`;
}
const chat = conversation();
chat.next().value;          // "What's your name?"
chat.next("Rohit").value;   // "Hello, Rohit!"
What’s happening
  1. conversation() returns a paused generator; nothing has run yet.
  2. chat.next() runs up to the first yield and hands out "What's your name?" → { value: "What's your name?", done: false }. The generator is now paused in the middle of const name = yield ... — the assignment hasn't happened.
  3. chat.next("Rohit") resumes it. The paused yield expression evaluates to "Rohit", so name = "Rohit", and execution runs to the next yield → { value: "Hello, Rohit!", done: false }.
  4. One more chat.next() would run off the end → { value: undefined, done: true }. (A return x at the end would make that value: x.)
  5. The argument to the first next() is always thrown away, because no yield is waiting to receive it yet. Each next(x) answers the previous yield — the off-by-one that trips people up.
  6. This is the redux-saga pattern: the saga yields a description of an effect, the middleware performs it, then calls next(result) to send the answer back in. co did the same with promises, and async/await is essentially that pattern built into the language.
Advancedreturn, throw and iterator helpers

Generators also have return(value), which finishes the generator early (running any finally blocks) and returns { value, done: true }, and throw(error), which throws the error at the paused yield as if that line had thrown. for...of calls return() for you when you break out of a loop.

Lazy pipelines with iterator helpers (ES2025, modern browsers and Node 22+):

JavaScript
idGenerator()
  .filter((n) => n % 2 === 0)
  .map((n) => n * 10)
  .take(3)
  .toArray(); // [20, 40, 60] — only 6 values were ever generated
What’s happening
  1. idGenerator() creates the infinite generator. .filter, .map and .take(3) each wrap the previous iterator in a new lazy iterator — no value has been produced yet. These methods live on Iterator.prototype, which every generator object inherits from.
  2. .toArray() starts pulling: it asks take for a value, take asks map, map asks filter, and filter asks the generator. The generator yields 1; the filter rejects it and pulls again; 2 passes; map turns it into 20; take counts 1 of 3; toArray stores 20.
  3. Same again: 3 is rejected and 4 becomes 40; 5 is rejected and 6 becomes 60. take has now delivered 3 values.
  4. On the next pull, take sees its limit is reached, so it calls return() down the chain — closing the generator, which would run any finally block in it — and reports done. toArray returns [20, 40, 60]. The generator produced exactly 1 through 6.
  5. Compare with array methods: .filter().map().slice(0, 3) on an array builds a complete intermediate array at each step, which is impossible with an infinite source. Iterator helpers push one value at a time through the whole pipeline.

#Async iterators and for await...of

Why for await exists: a regular iterator's next() must return { value, done } synchronously. Data that arrives over time — paginated APIs, streams, sockets — can't do that. An async iterator's next() returns a promise of { value, done }, and for await...of awaits each one in turn.

The protocol mirrors the sync one: an async iterable has a [Symbol.asyncIterator]() method that returns an async iterator. The easiest way to write one is an async generator (async function*), which can both await (to wait for data) and yield (to hand a value to the consumer). Each yield fulfils the promise that the consumer's next() returned.

JavaScript
// Async generator: yield + await in the same function
async function* fetchAllPages(url) {
  while (url) {
    const res = await fetch(url);
    const { items, nextUrl } = await res.json();
    yield items;
    url = nextUrl;
  }
}

for await (const page of fetchAllPages("/api/products?page=1")) {
  render(page); // each page as soon as it arrives; stops when url is null
}
What’s happening
  1. fetchAllPages(...) returns an async generator object. Like any generator, its body hasn't started, so nothing has been fetched.
  2. for await calls next(), which immediately returns a pending promise, and the body starts: it sends the request for page 1 and suspends at await fetch(url). The loop is now awaiting that next() promise.
  3. When the response arrives and the JSON is parsed, the body reaches yield items. That fulfils the next() promise with { value: items, done: false }, and the generator pauses at the yield.
  4. The loop body runs render(page). Only when it finishes does for await call next() again; the generator resumes after yield, sets url = nextUrl, and fetches page 2. So page 2 isn't requested until page 1 has been rendered — the consumer sets the pace, and nothing is fetched that won't be used.
  5. On the last page nextUrl is null, so while (url) exits, the function returns, and next() fulfils with { value: undefined, done: true }, ending the loop. If render throws or you break, for await calls the generator's return(), so no more pages are fetched.
AdvancedReading files line by line with streams

Node.js streams are async iterable too, which makes reading big files line by line simple:

JavaScript
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

const lines = createInterface({ input: createReadStream("huge.log") });
for await (const line of lines) {
  if (line.includes("ERROR")) console.log(line);
}
What’s happening
  1. createReadStream("huge.log") opens the file and reads it in chunks (64 KiB by default), never loading the whole file into memory.
  2. createInterface splits that stream of chunks into lines, including lines that straddle two chunks, and exposes them as an async iterable.
  3. for await receives one line per iteration; the loop body checks it and logs the ERROR lines. Memory use stays small however big the file is.
  4. If you break out early (say, after the first match), for await calls return(), which closes the interface and destroys the file stream.
Advancedfor await vs Promise.all

#requestAnimationFrame

requestAnimationFrame(callback) asks the browser to run callback right before the next repaint — once per frame, matched to the display's refresh rate (usually 60 Hz, 120 Hz on newer screens).

Where it sits in the event loop: after a task and its microtasks, when the browser decides to render, it runs all queued rAF callbacks → recalculates styles and layout → paints.

Why that matters: the browser draws a new frame roughly every 16.7 ms at 60 Hz. Anything you change in the DOM is only seen when the next frame is painted. Code that runs at the start of a rendering step gets to make its changes just in time for that frame — not too early (the change is overwritten or wasted) and not too late (it misses the frame).

JavaScript
const box = document.querySelector(".box");
let start;

function step(timestamp) {           // timestamp: ms since page load, high precision
  start ??= timestamp;
  const elapsed = timestamp - start;
  const x = Math.min(elapsed / 5, 300); // move 300px over 1.5s
  box.style.transform = `translateX(${x}px)`;
  if (x < 300) requestAnimationFrame(step); // schedule the next frame
}

requestAnimationFrame(step);
What’s happening
  1. The last line registers step for the next frame and returns an id. Nothing runs yet.
  2. At the next frame the browser calls step(timestamp); timestamp is that frame's time on the same clock as performance.now(). start ??= timestamp assigns only while start is undefined, so it records the first frame's time once. On this first call elapsed is 0 and x is 0.
  3. Setting box.style.transform doesn't paint anything by itself. After all rAF callbacks for this frame have run, the browser recalculates styles, lays out and paints, so the new position appears in this same frame.
  4. Since x < 300, step schedules itself again. A rAF callback registered during a rAF callback runs in the next frame, not this one — so this is one call per frame, not an infinite loop.
  5. Movement is calculated from elapsed time, not frame count. At 60 Hz each frame is ~16.7 ms later, so the box moves ~3.3 px; at 120 Hz, ~1.7 px. Either way it reaches 300 px at 1.5 s. If a frame is dropped, the next one simply jumps further instead of the animation slowing down.
  6. Once elapsed reaches 1500 ms, Math.min clamps x to exactly 300, the box lands at its final position, and no further frame is requested, so the loop stops by itself.
AdvancedrAF vs setTimeout timing

Why not setTimeout(step, 16)?

  • Timers drift and aren't aligned with the screen's refresh, causing dropped or doubled frames (jank).
  • rAF pauses in background tabs, saving battery and CPU.
  • Multiple DOM changes in one rAF callback are painted together in a single frame.

Also useful for batching DOM reads and writes and for throttling expensive scroll/resize handlers to once per frame. Cancel with cancelAnimationFrame(id).

AdvancedCSS and Web Animations instead of rAF

#Web Workers

A Web Worker runs JavaScript on a separate thread, so heavy computation doesn't freeze the UI. Workers can't touch the DOM; they communicate with the page by passing messages.

The main thread runs your JavaScript and handles input, layout and painting, so a two-second calculation there means two seconds of frozen page. A worker is a real OS thread with its own event loop and its own global scope (self instead of window). It shares no variables with the page. Picture a colleague in another room: you can only slide notes under the door, and each note is photocopied on the way through.

main.js
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });

worker.postMessage({ numbers: Array.from({ length: 1e7 }, (_, i) => i) });

worker.onmessage = (event) => {
  console.log("Sum from worker:", event.data);
};
worker.onerror = (e) => console.error(e.message);
AdvancedWorker side: summing in worker.js
worker.js
self.onmessage = (event) => {
  const { numbers } = event.data;
  let sum = 0;
  for (const n of numbers) sum += n; // the page stays responsive meanwhile
  self.postMessage(sum);
};
What’s happening
  1. new Worker(...) starts a new thread and loads worker.js in it. new URL("./worker.js", import.meta.url) resolves the path relative to the current module (and lets bundlers like Vite and webpack find the file); { type: "module" } allows import inside the worker.
  2. Array.from({ length: 1e7 }, ...) builds 10 million numbers on the main thread, so this line itself takes time. postMessage then structured-clones the object: the worker gets a copy, not a reference.
  3. onmessage is assigned after postMessage, and that's fine: the reply arrives as a message event — a macrotask on the main thread — which can't run until the current script has finished. By then the handler is in place.
  4. In the worker, self.onmessage fires with event.data holding the copied object. The loop sums 0 to 9,999,999, blocking only the worker's thread; the page keeps scrolling and responding.
  5. self.postMessage(sum) sends 49999995000000 back, and the page's onmessage logs it. If worker.js throws an uncaught error, the page's onerror receives an ErrorEvent whose .message describes it.
  6. A worker keeps running until you stop it: worker.terminate() from the page or self.close() from inside.
AdvancedStructured clone cost for messages

Messages are copied with the structured clone algorithm — fine for small data, slow for big data.

Structured clone is a deep copy that handles more than JSON does — Map, Set, Date, typed arrays and circular references all survive — but functions can't be cloned (it throws a DataCloneError), and class instances arrive as plain objects without their prototype or methods.

#Transferables

Advanced

Instead of copying, some objects can be transferred: ownership moves to the other thread in near-zero time, and the sender loses access.

Copying a 100 MB buffer costs time on both sides and briefly doubles memory use. Transferring hands over the underlying memory itself, so the cost doesn't depend on the size.

JavaScript
const buffer = new ArrayBuffer(100 * 1024 * 1024); // 100 MB
console.log(buffer.byteLength); // 104857600

worker.postMessage(buffer, [buffer]); // second argument: the list to transfer

console.log(buffer.byteLength); // 0 — "detached", it now belongs to the worker
What’s happening
  1. new ArrayBuffer(100 * 1024 * 1024) allocates 100 MiB, so byteLength is 104857600.
  2. postMessage(buffer, [buffer]): the first argument is the message, the second is the transfer list. Because buffer appears in both, it's moved instead of copied.
  3. The move happens synchronously, inside postMessage. On the very next line the page's buffer is detached: byteLength is 0 (and buffer.detached is true in current engines). Creating a typed-array view on it now throws a TypeError.
  4. The worker receives an ArrayBuffer of the full 104,857,600 bytes, backed by the same memory — no copy was made.
  5. Why detach instead of sharing? If both threads could write the same memory without coordination you'd get data races. Transfer guarantees exactly one owner at a time. When you genuinely need shared memory, that's SharedArrayBuffer plus Atomics (which browsers only allow on cross-origin-isolated pages).

Transferable types include ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas and streams. structuredClone(value, { transfer: [buffer] }) uses the same mechanism.

#Streaming JSON parsing

Advanced

JSON.parse is synchronous: parsing a 200 MB response blocks the event loop for the whole time — no clicks, no rendering. Options, from simplest:

  1. Move the parse to a Worker so only the worker thread blocks.
  2. Ask the server for NDJSON (one JSON object per line) and parse incrementally as bytes arrive.
  3. In Node, use a streaming parser library such as stream-json.

res.json() has the same problem: it waits for the entire body and then parses it in one go. NDJSON (newline-delimited JSON) fixes it at the format level — each line is a complete, small JSON document, so you can parse and use each record as soon as its line has arrived, with short pauses between chunks where the browser can handle input and paint.

ndjson.js
async function* readNdjson(url) {
  const res = await fetch(url);
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    const lines = buffer.split("\n");
    buffer = lines.pop(); // the last line may be incomplete — keep it for later
    for (const line of lines) {
      if (line.trim()) yield JSON.parse(line); // small parses, the UI stays responsive
    }
  }
  if (buffer.trim()) yield JSON.parse(buffer);
}

for await (const record of readNdjson("/api/export.ndjson")) {
  addRow(record);
}
What’s happening
  1. await fetch(url) fulfils as soon as the response headers arrive; the body is still downloading. res.body is a ReadableStream of bytes.
  2. pipeThrough(new TextDecoderStream()) turns the byte chunks into text, correctly handling a multi-byte UTF-8 character that's split across two chunks. getReader() gives you a reader that pulls one chunk at a time.
  3. Each await reader.read() waits for the next chunk. Chunk boundaries are arbitrary — a chunk can end in the middle of a line. Say the first chunk is {"id":1}\n{"id": buffer.split("\n") gives ['{"id":1}', '{"id"'], lines.pop() keeps the incomplete '{"id"' in buffer, and { id: 1 } is yielded.
  4. The next chunk is :2}\n. Appended to the leftover, buffer is '{"id":2}\n'; splitting gives ['{"id":2}', ''], so { id: 2 } is yielded and buffer becomes "". The if (line.trim()) skips any blank lines.
  5. A final chunk {"id":3} has no trailing newline, so it stays in buffer. When the stream ends, read() returns done: true, the loop breaks, and the code after it parses the leftover — which is why that last if (buffer.trim()) line exists.
  6. On the consumer side, for await receives one record at a time and addRow can display it immediately. Each JSON.parse is tiny, memory stays bounded to about one chunk plus one partial line, and between chunks the thread is free for clicks and rendering.

Built from Rohit’s “Javascript/HTML interview” study doc. Examples target modern browsers and Node 20+.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.