Skip to main content

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 weird setTimeout 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

  1. Call Stack: Synchronous code executes here (LIFO). Think of it as the waiter’s hands — they can only carry one thing at a time.
  2. 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.
  3. Task Queue (Callback Queue): Completed callbacks wait in line here, like dishes on the pickup counter.
  4. 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.
Practical gotcha: Because microtasks drain completely before the next render frame, an infinite chain of .then() calls can freeze the UI just as badly as a synchronous while(true) loop. If you need to yield to the browser for rendering, use setTimeout (macrotask) or requestAnimationFrame instead of a Promise chain.

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

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


4. async/await (ES2017)

async/await is syntactic sugar over Promises. It makes async code look synchronous.

Basic Usage

The Evolution of Async JavaScript

Decision guide — when to use which:
  • 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 manual new 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):
Parallel (Fast):
Rule of thumb: If async operations do not depend on each other’s results, use Promise.all to run them in parallel. Only use sequential await when the next call needs data from the previous one (e.g., fetching a user then fetching that user’s orders).

Async Error Handling Edge Cases

Top-Level Await

In ES modules, you can use await at the top level.
Top-level await blocks module loading. If you await a slow operation at the top level of a module, any module that imports it will also be blocked until that await resolves. Use it for essential setup (loading config, establishing database connections), not for optional operations. In application entry points it is fine; in library modules it can cause surprising slowdowns for consumers.

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

Debounce vs Throttle: Use debounce when you want to wait until activity stops (search input, window resize). Use throttle when you want steady updates during continuous activity (scroll position, mouse movement, game loop ticks).

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+.
The most common fetch mistake: Assuming fetch throws on 404 or 500 responses. It does not. A try/catch around fetch only catches network failures, not HTTP error codes. Always check response.ok or response.status before processing the body.

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/catch for errors.
  • Parallel Execution: Use Promise.all() to run independent operations concurrently.
Next, we’ll explore Modern JavaScript (ES6+) features that make the language more powerful and expressive.

Interview Deep-Dive

Strong Answer:
  • Output: A, F, C, E, B, D. Here is the step-by-step reasoning:
  • Synchronous phase: console.log('A') executes immediately — output: A. setTimeout is 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 .then callback is placed on the microtask queue (not executed yet). Same for the second and third .then callbacks — 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 setTimeout callback for ‘B’ — output: B. After each macrotask, the microtask queue is drained (it is empty). Next macrotask: the setTimeout callback 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.
Follow-up: Can a microtask starve macrotasks? What would that look like, and how would it manifest as a bug?Yes. If a .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.
Strong Answer:
  • Promise.all: Waits for all promises to fulfill. If any single promise rejects, the entire Promise.all rejects 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 need AbortController for true cancellation.
  • Promise.any: Returns the first promise to fulfill. Rejections are ignored unless ALL promises reject (then it throws AggregateError). 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 all if any failure is fatal, allSettled if you want per-item status. Do you need just one result? Use race if you care about the first settlement (even failures), any if you only care about the first success.
Follow-up: Promise.all rejects on the first failure, but the other promises keep running. How do you actually cancel in-flight requests when one fails?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.
Strong Answer:
  • Basic trailing-edge debounce:
  • Closure analysis: the returned function closes over timeoutId and fn. Every call to the debounced function accesses the same timeoutId variable through the closure. When called rapidly, each call clears the previous timeout (via clearTimeout(timeoutId)) and sets a new one. Only the last call’s timeout actually fires. The fn reference is also captured in the closure, so the original function is available when the timeout finally executes.
  • The fn.apply(this, args) detail matters: using apply preserves the this context and the arguments from the call site. If you just wrote fn(...args), you would lose this — 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 immediate flag:
  • Production consideration: Lodash’s _.debounce supports both leading and trailing (and both simultaneously), a maxWait option (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.
Follow-up: What happens if the debounced function is called with different arguments during the delay period? Which arguments “win”?In a trailing-edge debounce, the last call’s arguments win. Each call to the debounced function clears the previous timeout and sets a new one with the latest 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.
Strong Answer:
  • fetch only 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: fetch is 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 HttpError class 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 fetch wrapper 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(); }. The fetch succeeded with a 500 status, .json() tried to parse an HTML error page, threw a SyntaxError, and the catch block showed a generic error message with no status code context. The fix was checking response.ok before calling .json().
Follow-up: How do you implement a timeout for fetch? The native API has no timeout option.Use 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.
Strong Answer:
  • 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.all is 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) or worker_threads (Node.js). These run JavaScript on separate OS threads with their own event loops. Communication happens via postMessage (structured cloning) or SharedArrayBuffer (shared memory with Atomics for synchronization).
  • The practical implication: Promise.all is “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.
Follow-up: If I create 1000 fetch calls with Promise.all, will they all fire at once? What limits them?They will all be initiated immediately at the JavaScript level, but the browser and OS impose limits. Browsers enforce a per-origin connection limit (Chrome allows 6 concurrent HTTP/1.1 connections per host). Requests beyond the limit are queued internally by the browser’s networking stack. With HTTP/2, multiple requests share a single TCP connection via multiplexing, so the per-connection limit is less relevant, but the browser still limits the total number of concurrent streams. In Node.js, the limit comes from the underlying libuv thread pool (default 4 threads for DNS resolution) and OS socket limits. In production, blindly firing 1000 concurrent requests can overwhelm the target server, trigger rate limiting, or exhaust local file descriptors. The solution is a concurrency limiter: process in batches of N (e.g., 10) using a pattern like p-limit or a manual semaphore with async/await.