Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Async JavaScript
JavaScript is single-threaded but non-blocking. It achieves this through the Event Loop. Understanding async is essential for handling I/O, API calls, timers, and user events. This is the chapter where JavaScript goes from “a scripting language” to “the engine that powers the modern web.” Every network request, every animation, every user interaction relies on the async machinery described here. Master this and you will never be confused by a weirdsetTimeout output order again.
1. The Event Loop
JavaScript has one main thread. Long operations would block everything. The solution? Asynchronous callbacks. The restaurant host analogy: Imagine a restaurant with a single waiter (the main thread). If the waiter stood at the kitchen window waiting for every dish to be cooked before taking the next order, the restaurant would be unbearably slow. Instead, the waiter takes your order, hands it to the kitchen (Web APIs), and immediately goes to serve the next table. When the kitchen rings the bell (task queue), the waiter picks up the dish and delivers it — but only when they are not in the middle of serving someone else. The event loop is the waiter checking: “Am I free? Is there a dish ready?” That is how JavaScript handles thousands of concurrent operations on a single thread.How It Works
- Call Stack: Synchronous code executes here (LIFO). Think of it as the waiter’s hands — they can only carry one thing at a time.
- Web APIs: The browser (or Node.js) handles async operations in separate threads (setTimeout, fetch, DOM events). This is the kitchen — work happens in parallel, out of sight.
- Task Queue (Callback Queue): Completed callbacks wait in line here, like dishes on the pickup counter.
- Event Loop: The loop continuously checks: “Is the call stack empty? If yes, move the next callback from the queue to the stack.” This is the waiter glancing at the counter.
Microtasks vs Macrotasks
There are actually two queues, and understanding the difference is what separates junior from senior JavaScript developers. Microtasks (Promises,queueMicrotask, MutationObserver) have higher priority than Macrotasks (setTimeout, setInterval, I/O, UI rendering). After every macrotask (or after the call stack empties), the event loop drains the entire microtask queue before moving to the next macrotask. This means a microtask can starve macrotasks if it keeps creating more microtasks.
2. Callbacks
A callback is a function passed to another function to be executed later.Callback Hell
Nested callbacks become unreadable and hard to maintain. Each level of nesting adds indentation and makes error handling nearly impossible — you would need a separate error callback at every level.3. Promises
A Promise represents a value that may be available now, later, or never. It’s a cleaner alternative to callbacks.Promise States
| State | Description |
|---|---|
pending | Initial state, neither fulfilled nor rejected |
fulfilled | Operation completed successfully |
rejected | Operation failed |
Creating a Promise
Consuming a Promise
Chaining Promises
Each.then() returns a new Promise, enabling chaining.
Promise Static Methods
These are your tools for orchestrating multiple async operations. Choosing the right one matters.Promise Static Methods — Complete Comparison
| Method | Resolves when | Rejects when | Short-circuits? | Use case |
|---|---|---|---|---|
Promise.all | All fulfill | Any one rejects | Yes (first rejection) | Need all results; any failure is fatal |
Promise.allSettled | All settle (fulfill or reject) | Never rejects | No | Need outcome of every operation regardless |
Promise.race | First to settle (fulfill or reject) | First to settle is a rejection | Yes (first settlement) | Timeouts, fastest source wins |
Promise.any | First to fulfill | All reject (AggregateError) | Yes (first fulfillment) | Fallback sources, try until one works |
4. async/await (ES2017)
async/await is syntactic sugar over Promises. It makes async code look synchronous.
Basic Usage
The Evolution of Async JavaScript
| Pattern | Era | Error Handling | Readability | Composability |
|---|---|---|---|---|
| Callbacks | Pre-2015 | Error-first convention (manual) | Poor (pyramid of doom) | Difficult |
Promises (.then) | ES2015 | .catch() (chainable) | Moderate (chain can grow long) | Good (chaining, Promise.all) |
| async/await | ES2017 | try/catch (familiar) | Excellent (reads like sync code) | Good (combine with Promise.all) |
- async/await: Your default for all async code. Readable, debuggable, supports try/catch.
- Promise chains (
.then): When you need to transform a value inline without naming intermediate variables, or when building reusable Promise pipelines. - Callbacks: Only when forced by an older API (some Node.js APIs, third-party libraries). Wrap in a Promise immediately using
util.promisify(Node.js) or a manualnew Promise()wrapper.
Error Handling
Parallel Execution
This is one of the most common performance mistakes in async JavaScript. The difference between sequential and parallel can be the difference between a 3-second page load and a 1-second page load. Sequential (Slow):Async Error Handling Edge Cases
Top-Level Await
In ES modules, you can useawait at the top level.
5. Common Async Patterns
Retry with Exponential Backoff
A production-essential pattern. Network requests fail, servers hiccup, and connections drop. Retrying with increasing delays (1s, 2s, 4s) avoids hammering a struggling server while still recovering from transient failures.Debounce
Wait for a pause in activity before executing. Think of it like an elevator door: it resets its close timer every time someone walks in. It only closes after nobody has entered for a set period.Throttle
Limit execution to at most once per interval, no matter how many times it is triggered. Think of it like a rate limiter: “You may only do this once every N milliseconds.”Debounce vs Throttle — Complete Comparison
| Feature | Debounce | Throttle |
|---|---|---|
| Fires when | Activity STOPS for N ms | At most once every N ms during activity |
| During rapid calls | Keeps resetting, never fires until pause | Fires at regular intervals, drops extras |
| First call | Delayed (unless using leading edge variant) | Immediate (then locked for N ms) |
| Best for | Search input, form validation, window resize end | Scroll tracking, mouse move, game loops, API rate limiting |
| Analogy | Elevator door (waits for no one else to enter) | Heartbeat (steady pulse regardless of stimulus) |
Async Queue
Process items one at a time.6. Fetch API
The modern way to make HTTP requests, built into every browser and Node.js 18+.Summary
- Event Loop: Enables non-blocking I/O on a single thread.
- Callbacks: Simple but lead to callback hell.
- Promises: Chainable, cleaner error handling with
.catch(). - async/await: Synchronous-looking async code. Use
try/catchfor errors. - Parallel Execution: Use
Promise.all()to run independent operations concurrently.
Interview Deep-Dive
Walk me through the exact execution order of this code and explain why. Include microtask and macrotask reasoning.
Walk me through the exact execution order of this code and explain why. Include microtask and macrotask reasoning.
- Output: A, F, C, E, B, D. Here is the step-by-step reasoning:
- Synchronous phase:
console.log('A')executes immediately — output: A.setTimeoutis called, its callback is registered with the Web API timer and will be placed on the macrotask queue after 0ms.Promise.resolve().then(...)— the promise is already resolved, so the.thencallback is placed on the microtask queue (not executed yet). Same for the second and third.thencallbacks — all three go onto the microtask queue.console.log('F')executes immediately — output: F. The synchronous call stack is now empty. - Microtask drain phase: The event loop checks the microtask queue before touching the macrotask queue. First microtask:
console.log('C')— output: C. Second microtask:setTimeout(() => console.log('D'), 0)— this schedules another macrotask. No console output. Third microtask:console.log('E')— output: E. Microtask queue is now empty. - Macrotask phase: The event loop picks the first macrotask: the
setTimeoutcallback for ‘B’ — output: B. After each macrotask, the microtask queue is drained (it is empty). Next macrotask: thesetTimeoutcallback for ‘D’ (which was scheduled during the microtask phase) — output: D. - The critical insight: microtasks that are added during the microtask drain phase are also drained in the same cycle. And microtasks can schedule macrotasks, but those macrotasks always run after the current microtask drain is complete. This is why C and E appear before B, even though the setTimeout for B was registered before any of the promise callbacks.
.then() callback schedules another .then(), and that one schedules another, the microtask queue never empties. The event loop never reaches the macrotask queue, which means setTimeout callbacks never fire, DOM rendering never happens (the browser renders between macrotasks), and the UI freezes completely. Example: function loop() { Promise.resolve().then(loop); } loop(); — this is an infinite microtask loop that freezes the tab just as effectively as while(true){}. I have seen this in production when a recursive promise chain had no base case — a function that retried a failed API call by chaining another .then() without ever falling through to a setTimeout-based backoff. The fix was introducing setTimeout in the retry path to yield to the macrotask queue and let the browser breathe.What is the difference between Promise.all, Promise.allSettled, Promise.race, and Promise.any? When would you use each in production?
What is the difference between Promise.all, Promise.allSettled, Promise.race, and Promise.any? When would you use each in production?
Promise.all: Waits for all promises to fulfill. If any single promise rejects, the entirePromise.allrejects immediately (fail-fast). Use when you need all results and a single failure means the whole operation is useless. Example: loading a dashboard that needs user data, permissions, and feature flags — if any fails, the dashboard cannot render.Promise.allSettled: Waits for all promises to settle (fulfill or reject). Never short-circuits. Returns an array of{status, value}or{status, reason}objects. Use when failures are expected and you want to handle each result independently. Example: sending analytics events to three different providers — if one is down, you still want the other two results. Or a batch API migration where you want a report of which items succeeded and which failed.Promise.race: Returns the result of the first promise to settle (fulfill OR reject). Use for timeouts:Promise.race([fetch(url), timeout(5000)])— if the fetch takes longer than 5 seconds, the timeout promise rejects first, and you handle the timeout. Caveat: if the losing promise is a fetch, it is not actually cancelled — it continues running in the background. You needAbortControllerfor true cancellation.Promise.any: Returns the first promise to fulfill. Rejections are ignored unless ALL promises reject (then it throwsAggregateError). Use when you have multiple fallback sources and want the fastest success. Example: fetching from a CDN and an origin server simultaneously — use whichever responds successfully first.- Production decision framework: Do you need all results? Use
allif any failure is fatal,allSettledif you want per-item status. Do you need just one result? Useraceif you care about the first settlement (even failures),anyif you only care about the first success.
Promise.all does not cancel anything — promises are not cancellable by design. The other fetches continue running, consuming bandwidth and server resources. The solution is AbortController. Create one controller, pass its signal to every fetch, and call controller.abort() in the .catch(). Example: const controller = new AbortController(); try { await Promise.all(urls.map(url => fetch(url, { signal: controller.signal }))); } catch (e) { controller.abort(); }. Each aborted fetch throws an AbortError, which you can distinguish from real errors by checking error.name === 'AbortError'. In Node.js, you can also pass abort signals to stream pipelines and database queries. Without this pattern, I have seen production systems where a failed dashboard load left 12 orphaned API calls running, each holding open a database connection for their full timeout duration.Implement a debounce function from scratch. Then explain how it uses closures, and tell me the difference between leading-edge and trailing-edge debounce.
Implement a debounce function from scratch. Then explain how it uses closures, and tell me the difference between leading-edge and trailing-edge debounce.
- Basic trailing-edge debounce:
- Closure analysis: the returned function closes over
timeoutIdandfn. Every call to the debounced function accesses the sametimeoutIdvariable through the closure. When called rapidly, each call clears the previous timeout (viaclearTimeout(timeoutId)) and sets a new one. Only the last call’s timeout actually fires. Thefnreference is also captured in the closure, so the original function is available when the timeout finally executes. - The
fn.apply(this, args)detail matters: usingapplypreserves thethiscontext and the arguments from the call site. If you just wrotefn(...args), you would losethis— which matters when the debounced function is used as an object method. - Trailing-edge (default): the function fires after the delay, once activity stops. Use case: search-as-you-type — fire the API call 300ms after the user stops typing.
- Leading-edge: the function fires immediately on the first call, then ignores subsequent calls for the delay period. Use case: a submit button — execute immediately, then prevent double-clicks for 500ms.
- Leading-edge implementation adds an
immediateflag:
- Production consideration: Lodash’s
_.debouncesupports both leading and trailing (and both simultaneously), amaxWaitoption (fire at least every N ms even if activity continues), and a.cancel()method to abort a pending invocation. If you need more than basic debounce, use the library.
args. When the timeout finally fires, it uses the arguments from the final invocation. This is correct for search-as-you-type: the query “hel” is replaced by “hell” is replaced by “hello”, and only “hello” is sent to the API. If you needed to accumulate all arguments (for example, batching analytics events), you would modify the debounce to collect args into an array and pass the array to fn when the timeout fires.The fetch API does not throw on 404 or 500 responses. Why is this a design decision, not a bug? How should you handle it in production?
The fetch API does not throw on 404 or 500 responses. Why is this a design decision, not a bug? How should you handle it in production?
fetchonly rejects on network-level failures: DNS resolution failure, server unreachable, CORS blocked, connection reset. HTTP error responses (4xx, 5xx) are successful network operations — the server received the request and sent back a response. From the network layer’s perspective, that is a success. The error is at the application layer, which is your responsibility to handle.- This design aligns with the separation of concerns principle:
fetchis a network transport API, not an application-level HTTP client. It tells you “I got a response from the server.” What that response means is your domain logic. - In production, the standard pattern is a wrapper:
- The custom
HttpErrorclass preserves the status code and response body, so calling code can distinguish between a 401 (redirect to login), 404 (show not-found UI), and 500 (show error page with retry button). - Libraries like Axios throw on non-2xx status codes by default, which is why many teams prefer Axios — it matches the “throw on error” mental model. The trade-off is an additional dependency and a slightly different API. In modern code, a thin
fetchwrapper gives you the same behavior with zero dependencies. - Production gotcha I have encountered: a team had
try { const data = await fetch(url).then(r => r.json()); } catch (e) { showError(); }. Thefetchsucceeded with a 500 status,.json()tried to parse an HTML error page, threw aSyntaxError, and the catch block showed a generic error message with no status code context. The fix was checkingresponse.okbefore calling.json().
AbortController with setTimeout: const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); return response; } catch (e) { if (e.name === 'AbortError') throw new Error('Request timed out'); throw e; }. The signal option was added to fetch specifically for cancellation. When abort() is called, the fetch rejects with an AbortError. You clear the timeout on success to prevent it from firing after the response arrives. In Node.js 18+, fetch also supports the signal option natively. Some teams wrap this into a fetchWithTimeout utility that is used project-wide.Explain the difference between concurrency and parallelism in JavaScript. Is Promise.all truly parallel?
Explain the difference between concurrency and parallelism in JavaScript. Is Promise.all truly parallel?
- Concurrency means multiple tasks make progress over the same time period, but not necessarily at the same instant. Parallelism means multiple tasks execute simultaneously, at the same instant, on different CPU cores.
- JavaScript’s event loop provides concurrency, not parallelism. When you call
Promise.all([fetch(a), fetch(b), fetch(c)]), three network requests are initiated. The JavaScript main thread does not process them simultaneously — it fires off three I/O operations to the browser/OS networking layer (which may use multiple threads internally), then goes idle. As responses arrive, their callbacks are queued and processed one at a time on the main thread. - So
Promise.allis concurrent but not parallel at the JavaScript level. The network I/O itself happens in parallel (the OS handles multiple TCP connections simultaneously), but the JavaScript callback execution is serial — one.then()handler runs, then the next. - True parallelism in JavaScript requires
Web Workers(browser) orworker_threads(Node.js). These run JavaScript on separate OS threads with their own event loops. Communication happens viapostMessage(structured cloning) orSharedArrayBuffer(shared memory withAtomicsfor synchronization). - The practical implication:
Promise.allis “parallel enough” for I/O-bound work (API calls, database queries, file reads) because the I/O subsystem handles the actual parallelism. It is not enough for CPU-bound work (parsing a 100MB JSON file, running a complex algorithm) because those computations happen on the main thread sequentially. For CPU-bound parallel work, you need worker threads.