Skip to main content

Harshal V. LADHE

Mastering Asynchronous JavaScript: Callbacks, Promises, and Async/Await

Write clean, predictable asynchronous JavaScript
Published at:
Last updated:
Estimated reading time:18 min read

Introduction

JavaScript is single-threaded by design, yet it powers highly interactive, non-blocking applications. This apparent contradiction is resolved through asynchronous programming. Understanding how JavaScript handles async operations is essential for writing scalable, maintainable, and performant code.

This article walks step by step through:

  • What asynchronous JavaScript really means
  • The Event Loop and the Runtime Model
  • Callback-based patterns and their limitations
  • Promises as a structured alternative
  • async/await for clean, modern async code

The goal is not only to explain how these work, but why each evolution exists.

What Is Asynchronous JavaScript?

In synchronous code, each operation blocks the next until it completes:

  • A task starts
  • JavaScript waits for it to finish
  • Only then does the next task run

Asynchronous code allows JavaScript to:

  • Start a long-running task (network request, timer, file read)
  • Continue executing other code
  • Handle the result later, when it is ready

This is critical for:

  • Network requests (APIs)
  • Timers and delays
  • User interactions
  • Animations

JavaScript achieves this using the event loop, callbacks, and microtask queues — but developers interact with it through higher-level abstractions.

Why Asynchronous JavaScript Matters

Long-running operations — network requests, timers, user interactions — would freeze the entire page if JavaScript had to wait for each one to finish before doing anything else. Asynchronous programming lets JavaScript start these operations, keep running other code while they're in progress, and handle the result whenever it arrives.

This is especially important for:

Network requests

Fetching data from APIs can take hundreds of milliseconds or even seconds depending on network conditions. Asynchronous execution allows the application to remain responsive while waiting for the response.

User interface responsiveness

Browsers rely on the main thread for rendering and interaction. Blocking this thread with long operations would freeze the UI, preventing scrolling, clicks, or animations.

Timers and scheduled tasks

Functions like setTimeout and setInterval rely on asynchronous behavior to schedule code execution without interrupting the rest of the program.

Concurrent operations

Multiple asynchronous tasks — such as loading images, fetching data, and handling user input — can progress independently without forcing sequential execution.

In short, asynchronous programming enables JavaScript to manage waiting efficiently. Instead of stopping the entire program while a task completes, JavaScript schedules the result to be handled later, ensuring applications remain smooth and responsive.

The JavaScript Runtime Model: How Asynchronous Code Actually Runs

To truly understand asynchronous JavaScript, it helps to understand how JavaScript executes code under the hood.

Single-Threaded Nature

JavaScript runs on a single main thread, meaning only one piece of JavaScript executes at a time. Long synchronous tasks would block the UI, which is why asynchronous APIs exist.

Web APIs

Operations like fetch or setTimeout are offloaded to Web APIs provided by the browser. These APIs run outside the main thread. Once completed, they hand their result off to a task queue — covered next — which the Event Loop drains back onto the Call Stack.

The Event Loop

The event loop continuously checks:

  1. Is the call stack empty?
  2. Are there pending tasks in the queues?

If yes, it moves queued callbacks onto the call stack.

There are two important queues:

  • Macrotask queue (timers, events)
  • Microtask queue (promises, queueMicrotask)

Microtasks always run before the next macrotask.

Here's that same flow as a diagram: the Call Stack hands async work to the Web APIs, results land in a task queue, and the Event Loop drains that queue back onto the stack.

Event Loop Execution Flow (Visual Timeline)

Understanding the Event Loop becomes much easier when visualized as a step-by-step flow — the Microtask row below is highlighted because it always drains completely before a single Macrotask runs.

JavaScript event loop diagramA vertical flow: Call Stack hands work to Web APIs, which deliver results into a Task Queues box split into a Microtask row and a Macrotask row. The Event Loop checks those queues and loops back up to push the next callback onto the Call Stack once it’s empty. The Microtask row is highlighted to show it always drains first.Call Stackexecutes JS codeoffloads workWeb APIsfetch, setTimeout, DOMresult readyTask QueuesMicrotask QueuePromises, queueMicrotaskMacrotask QueuesetTimeout, eventsstack empty?Event Loopmoves tasks to the stackpushes next callbackMicrotask Queue — runs first, drains completelyMacrotask Queue — runs one, after microtasks drain

How Execution Happens

  1. JavaScript executes code from the Call Stack
  2. Async operations are offloaded to Web APIs
  3. When complete, callbacks are queued:
    • Promises → Microtask Queue
    • Timers/events → Macrotask Queue
  4. The Event Loop checks:
    • If the call stack is empty
    • Then executes all microtasks first
    • Then executes one macrotask
  5. The cycle repeats continuously

Key Insight

This is why promise callbacks run before setTimeout, even if the timeout is 0.

The Evolution of JavaScript Async Patterns

As JavaScript applications grew in complexity, the language evolved to provide better ways of managing asynchronous operations. Each generation of async patterns solved problems introduced by the previous one.

1. Callbacks

Callbacks were the original solution for asynchronous operations in JavaScript. A callback is simply a function passed into another function that executes once a task finishes.

While callbacks work well for simple cases, complex workflows quickly lead to deeply nested code structures, often referred to as callback hell.

2. Promises

Promises were introduced to provide a more structured way to represent asynchronous results. Instead of passing functions around manually, a promise represents a value that will eventually be available.

Promises allow developers to chain operations using .then() and handle failures using .catch(), significantly improving readability and error management.

3. Async/Await

async and await, introduced in ES2017, build on top of promises to make asynchronous code look synchronous. Instead of chaining .then() calls, developers can write sequential-looking code while still executing asynchronously.

This approach improves readability, simplifies error handling with try...catch, and aligns asynchronous code with familiar control flow patterns.

Summary of the Evolution

The progression of asynchronous JavaScript can be summarized as:

Callbacks → Promises → Async/Await

Each step reduces complexity, improves readability, and makes asynchronous logic easier to reason about in large applications.

Callbacks: The Original Pattern

A callback is a function passed as an argument to another function and executed after an asynchronous operation completes. Callbacks were the earliest and most common way to handle async behavior in JavaScript.

Basic Callback Example

function fetchData(callback) {
  setTimeout(() => {
    callback("Data loaded");
  }, 1000);
}

fetchData((result) => {
  console.log(result);
});

What's happening here:

  • setTimeout simulates an asynchronous operation
  • The callback is invoked once the operation finishes
  • Execution continues without blocking the main thread

For small, simple async tasks, callbacks are straightforward and effective.

Common Callback Patterns

Callbacks remain widely used today, especially in:

  • Event listeners
  • Streaming and data flow APIs
  • Low-level or performance-critical libraries

Error-First Callbacks

Many APIs (notably Node.js) use the error-first callback convention, where the first argument represents a potential error.

readFile(path, (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});

This pattern ensures errors are explicitly handled before accessing results.

Limitations of Callbacks

As application complexity increases, callbacks begin to show serious drawbacks:

  • No built-in chaining mechanism
  • Manual error propagation required
  • Inversion of control (you hand over execution flow)
  • Readability issues when nesting callbacks deeply

These limitations led to callback hell, motivating the evolution toward Promises and async/await.

Callback Hell: When Things Go Wrong

Problems arise when multiple async operations depend on each other.

Nested Callbacks Example

getUser(id, (user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0], (details) => {
      console.log(details);
    });
  });
});

This pattern is known as callback hell.

Why Callback Hell Is a Problem

  • Code becomes deeply nested and hard to read
  • Error handling is repetitive and fragile
  • Logic flow is difficult to follow
  • Refactoring becomes risky

Callback hell is not just about indentation — it is about loss of clarity.

Promises: A Better Abstraction

A Promise represents the eventual result of an asynchronous operation. It may be pending, fulfilled, or rejected. It provides a structured way to manage asynchronous operations and replace deeply nested callbacks.

Promise States:

A Promise is always in exactly one of these states:

  • Pending — initial state.
  • Fulfilled (Resolved) — The operation completed successfully.
  • Rejected — The operation failed.

Once settled (fulfilled or rejected), the state is immutable.

Promises allow you to chain operations using .then() and handle errors gracefully with .catch().

Creating and Consuming Promises

Creating a Promise

const fetchData = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Data loaded");
    }, 1000);
  });
};

Consuming a Promise

fetchData()
  .then((result) => {
    console.log(result);
  })
  .catch((error) => {
    console.error(error);
  }).finally(() => {
    console.log("Cleanup logic here")
  });

Promises flatten nested callbacks and centralize error handling, significantly improving readability.

Promise Chaining

Promises can be chained to represent sequential asynchronous steps.

getUser(id)
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetails(orders[0]))
  .then((details) => console.log(details))
  .catch((error) => console.error(error));

Benefits of Promise Chaining

  • Linear, readable flow
  • Single error-handling path
  • Easier composition of async logic

Promise Internals and Behavior

Understanding Promise behavior is critical for avoiding subtle bugs.

Promises Are Eager

  • Execution begins immediately when a Promise is created
  • Promises represent a result, not the operation itself
Promise.resolve(Promise.resolve(42)).then(console.log);

A common "gotcha" for learners: while the .then() block is asynchronous, the code inside the Promise constructor runs synchronously and immediately.

console.log("1. Before Promise");
new Promise((resolve) => {
  console.log("2. Inside Promise Constructor (Synchronous!)");
  resolve();
});
console.log("3. After Promise");

Promise Resolution Rules

  • Resolving with a value fulfills the Promise
  • Resolving with another Promise adopts its state
  • Throwing an error automatically rejects the Promise

These rules make Promise composition predictable and consistent.

Promise Utility Methods

JavaScript provides static Promise methods for common async coordination patterns.

Promise.all

Promise.all is fail-fast. It takes an array of promises and waits for all of them to succeed. However, if any single promise rejects, the entire operation fails immediately.

// Captures an array of results; fails if any task rejects
const results = await Promise.all([taskA(), taskB()]);

// Pro-Tip: Destructure for immediate access
const [dataA, dataB] = await Promise.all([taskA(), taskB()]);

Best Use Case: When the tasks are dependent on each other. For example, you need both a user_id and an access_token to load a dashboard. If you don't get both, the dashboard is useless.

Promise.allSettled

Promise.allSettled waits for every promise to finish, regardless of whether they succeeded or failed. It returns an array of objects describing the outcome of each.

// Captures an array of outcome objects {status, value/reason}
const outcomes = await Promise.allSettled([taskA(), taskB()]);

Best Use Case: When the tasks are independent. For example, loading five different widgets on a page. If the "Weather" widget fails, you still want the "Stock Market" and "News" widgets to display.

Promise.race

The Promise settles (either resolves or rejects) as soon as the first promise in the group settles.

// Settles as soon as the first promise settles (winner or error)
const firstSettled = await Promise.race([taskA(), taskB()]);

Best Use Case: Implementing a timeout for a network request. If the request doesn't finish in 5 seconds, the "timeout" promise wins the race and rejects.

Promise.any

The Promise resolves as soon as the first promise fulfills (succeeds). It ignores rejections unless every promise in the group fails.

// Resolves as soon as the first promise fulfills (ignores errors)
const firstSuccess = await Promise.any([taskA(), taskB()]);

Best Use Case: Requesting data from three different "mirror" servers. You don't care if two of them are down; you only need the first successful response.

Reading about these four methods only goes so far — the playground below runs all four against the same three simulated tasks, so you can watch each one settle differently and see that a "losing" task doesn't stop running just because the combinator already has.

Promise Combinator Playground

Three simulated tasks with adjustable delays and pass/fail switches — pick Promise.all, allSettled, race, or any and watch how each settles, plus which tasks keep running after it does.

import { useState } from "react";
import { runTask } from "./tasks";
import type { CombinatorKind, TaskConfig, TaskState } from "./types";
import "./shared/playground.css";
import "./shared/segmented-control.css";
import "./styles.css";

// Starting delay/failure config for the three simulated tasks — staggered delays make the "loser
// keeps running" behavior visible instead of all three settling at once.
const INITIAL_TASKS: TaskConfig[] = [
  { label: "Task A", delayMs: 600, shouldFail: false },
  { label: "Task B", delayMs: 1200, shouldFail: false },
  { label: "Task C", delayMs: 1800, shouldFail: false }
];

// The four combinators this playground can switch between, in the segmented control's display order.
const COMBINATORS: { key: CombinatorKind; label: string }[] = [
  { key: "all", label: "Promise.all" },
  { key: "allSettled", label: "Promise.allSettled" },
  { key: "race", label: "Promise.race" },
  { key: "any", label: "Promise.any" }
];

export default function App() {
  const [tasks, setTasks] = useState<TaskConfig[]>(INITIAL_TASKS);
  const [combinator, setCombinator] = useState<CombinatorKind>("all");
  const [taskStates, setTaskStates] = useState<TaskState[]>(tasks.map(() => ({ status: "pending", elapsedMs: null })));
  const [running, setRunning] = useState(false);
  const [outcome, setOutcome] = useState<string | null>(null);

  function updateTask(index: number, changes: Partial<TaskConfig>) {
    setTasks((prev) => prev.map((task, i) => (i === index ? { ...task, ...changes } : task)));
  }

  async function run() {
    setRunning(true);
    setOutcome(null);
    setTaskStates(tasks.map(() => ({ status: "pending", elapsedMs: null })));

    const startedAt = performance.now();
    const promises = tasks.map((task, index) =>
      runTask(task, (elapsedMs, failed) => {
        setTaskStates((prev) => prev.map((state, i) => (i === index ? { status: failed ? "rejected" : "resolved", elapsedMs } : state)));
      })
    );

    try {
      let result: unknown;
      switch (combinator) {
        case "all":
          result = await Promise.all(promises);
          break;
        case "allSettled":
          result = await Promise.allSettled(promises);
          break;
        case "race":
          result = await Promise.race(promises);
          break;
        case "any":
          result = await Promise.any(promises);
          break;
      }
      const elapsedMs = Math.round(performance.now() - startedAt);
      setOutcome(`Settled after ${elapsedMs}ms →\n${JSON.stringify(result, null, 2)}`);
    } catch (error) {
      const elapsedMs = Math.round(performance.now() - startedAt);
      setOutcome(`Rejected after ${elapsedMs}ms →\n${String(error)}`);
    }

    setRunning(false);
  }

  return (
    <main className="playground">
      <header className="playground-header">
        <h1>Promise Combinator Playground</h1>
        <p>
          Three simulated tasks, each with its own delay and pass/fail switch. Pick a combinator and run it — then
          watch the task list keep updating even after the combinator above it has already settled.
        </p>
      </header>

      <div className="segmented-control" role="radiogroup" aria-label="Promise combinator">
        {COMBINATORS.map((option) => (
          <label className="segment" key={option.key}>
            <input
              type="radio"
              name="combinator"
              checked={combinator === option.key}
              onChange={() => setCombinator(option.key)}
              disabled={running}
            />
            <span>{option.label}</span>
          </label>
        ))}
      </div>

      <section className="demo-area" aria-label="Task status">
        <ul className="task-list">
          {tasks.map((task, index) => {
            const state = taskStates[index];
            return (
              <li className="task-row" key={task.label}>
                <span className="task-label">{task.label}</span>
                <span className={`task-status task-status--${state.status}`}>
                  {state.status === "pending" && "pending…"}
                  {state.status !== "pending" && `${state.status} @ ${Math.round(state.elapsedMs ?? 0)}ms`}
                </span>
              </li>
            );
          })}
        </ul>
      </section>

      <fieldset className="control-group">
        <legend>Configure</legend>
        <div className="fields-grid fields-grid--3">
          {tasks.map((task, index) => (
            <div className="field" key={task.label}>
              <label htmlFor={`delay-${index}`}>
                {task.label} <span>delay</span>
              </label>
              <div className="field-input">
                <input
                  id={`delay-${index}`}
                  type="number"
                  min={100}
                  max={3000}
                  step={100}
                  value={task.delayMs}
                  onChange={(e) => updateTask(index, { delayMs: Number(e.target.value) })}
                  disabled={running}
                />
                <span className="unit-select">ms</span>
              </div>
              <label className="checkbox-field">
                <input
                  type="checkbox"
                  checked={task.shouldFail}
                  onChange={(e) => updateTask(index, { shouldFail: e.target.checked })}
                  disabled={running}
                />
                <span>Fails</span>
              </label>
            </div>
          ))}
        </div>
      </fieldset>

      <button className="toggle-btn" onClick={run} disabled={running}>
        {running ? "Running…" : "Run Combinator"}
      </button>

      {outcome && <p className="readout">{outcome}</p>}
    </main>
  );
}

Ln , Col TypeScript React5.7 KBUTF-8

Starting sandbox…

No console output yet.

No original version of /App.tsx to compare against.

The queueMicrotask() API

Sometimes you need to schedule a function to run asynchronously but immediately after the current task, without the overhead of creating a full Promise. This is where queueMicrotask() shines.

Microtask vs Macrotask: Execution Timeline

To truly understand execution order, consider this example:

console.log("1. Start");

setTimeout(() => {
  console.log("2. setTimeout (Macrotask)");
}, 0);

Promise.resolve().then(() => {
  console.log("3. Promise (Microtask)");
});

queueMicrotask(() => {
  console.log("4. queueMicrotask (Microtask)");
});

console.log("5. End");

Step-by-Step Execution

  1. Run synchronous code — logs "1. Start" and "5. End"
  2. Process microtasks, all of them — logs "3. Promise (Microtask)" and "4. queueMicrotask (Microtask)"
  3. Process exactly one macrotask — logs "2. setTimeout (Macrotask)"

Final Output

1. Start
5. End
3. Promise (Microtask)
4. queueMicrotask (Microtask)
2. setTimeout (Macrotask)

Visual Timeline

Here's that same execution order laid out as a timeline — the five steps reordered into the sequence they actually run in, grouped by phase:

Microtask vs macrotask execution order timelineFive steps in the order they actually execute: Start and End run synchronously first, then the Promise and queueMicrotask callbacks run as microtasks and drain completely, and only then does the setTimeout callback run as a single macrotask.1Start5End3Promise4queueMicrotask2setTimeoutSynchronousMicrotasks — drain fullyMacrotask — exactly oneSynchronous — runs immediatelyMicrotask — runs first, drains completelyMacrotask — runs one, after microtasks drain

Important Takeaways

  • Microtasks always run before macrotasks
  • All microtasks are executed completely before moving on
  • Even setTimeout(fn, 0) runs after microtasks
  • queueMicrotask and Promises behave similarly in priority

Microtask Starvation Warning

Because microtasks have higher priority, continuously adding microtasks can block macrotasks and even UI rendering.

Example:

function loop() {
  queueMicrotask(loop);
}

loop();

Async/Await: Modern Asynchronous JavaScript

Introduced in ES2017, async and await are "syntactic sugar" built on top of Promises. They allow you to write asynchronous code that looks and behaves like synchronous code, making it significantly more readable.

  • async: Declares that a function returns a promise.
  • await: Pauses the execution of the function until the promise resolves.

Basic Async/Await Example

async function fetchData() {
  const result = await new Promise((resolve) => {
    setTimeout(() => resolve("Data loaded"), 1000);
  });

  console.log(result);
}

Key points:

  • async functions always return a promise
  • await pauses execution inside the function until the promise resolves
  • The main thread is not blocked

What Async Functions Actually Return

An async function always returns a promise, even if you write a plain return statement inside it. If you return a value that isn't a promise, JavaScript wraps it in one automatically. If you return a promise, that promise's eventual result becomes the outer promise's result.

async function getNumber() {
  return 42; // not a promise — but the function still returns one
}

getNumber().then(console.log); // 42
console.log(getNumber()); // Promise {<fulfilled>: 42}

The reverse also holds: await works on non-promise values too. If you await something that isn't a promise, it's treated as already resolved and execution continues on the next microtask tick.

async function example() {
  const value = await 5; // not a promise — resolves immediately
  console.log(value); // 5
}

Refactoring Promise Chains with Async/Await

Promise Version

getUser(id)
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetails(orders[0]))
  .then((details) => console.log(details));

Async/Await Version

async function loadOrderDetails(id) {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  const details = await getOrderDetails(orders[0]);

  console.log(details);
}

Why Async/Await Is Preferred

  • Reads top-to-bottom
  • Easier debugging
  • Familiar control flow (try/catch, loops)

Error Handling with Async/Await

Error handling becomes straightforward using try...catch.

async function loadData() {
  try {
    const data = await fetchData();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

This mirrors synchronous error handling, making code more predictable.

Practical Example: Fetching JSON

Here's everything so far combined into the pattern you'll actually reach for most often: fetching data, parsing it as JSON, and handling both network failures and non-OK responses.

async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }

    const user = await response.json();
    return user;
  } catch (error) {
    console.error("Failed to load user:", error);
    throw error;
  }
}

Async/Await Under the Hood

async/await does not block execution.

Internally:

  • await splits the function into promise-based steps
  • Execution resumes via the microtask queue

This explains why:

console.log('start');
await Promise.resolve();
console.log('end');

still behaves asynchronously.

Top-Level Await

Traditionally, the await keyword could only be used inside an async function. This restriction meant developers often had to wrap initialization code inside an async function or an asynchronous IIFE.

Modern JavaScript modules introduced Top-Level Await, allowing await to be used directly at the module level without wrapping it inside an async function.

This feature simplifies startup logic in ES modules, especially when loading configuration data, initializing resources, or fetching initial application state.

Example

const data = await fetch("/api/data").then(response => response.json());

console.log(data);

Where Top-Level Await Works

Top-level await is supported in:

  • ES modules (type="module" in browsers)
  • Modern Node.js environments
  • Build systems that support ES module syntax

When to Use It

Top-level await is useful for:

  • Loading configuration before application startup
  • Fetching initialization data
  • Dynamic module loading
  • Simplifying setup scripts

However, because modules that use top-level await pause execution until the awaited promise resolves, it should be used carefully in performance-sensitive code.

The Self-Starter: Async IIFE

An IIFE (pronounced "iffy") stands for Immediately Invoked Function Expression — a function that runs as soon as it is defined. When combined with async, it allows you to use await inside a script without having to formally name a function and call it later. It creates a private, asynchronous execution context.

(async () => {
  try {
    const data = await fetchData();
    console.log("Initialization complete:", data);
  } catch (err) {
    console.error("Failed to start app:", err);
  }
})();

Why Use It?

  • Top-Level Await Alternative: Useful in environments (like older Node.js versions or certain <script> tags) where you can't use await outside of a function.
  • Encapsulation: It keeps your variables out of the global namespace, preventing "variable pollution" and ensuring your logic doesn't leak into the window or global objects.
  • Fire-and-Forget Initialization: It's the perfect pattern for setup logic that needs to run exactly once when the script loads, such as connecting to a database or fetching initial configuration.

Async Loops and Iteration

Avoid forEach with async code.

Incorrect

items.forEach(async (item) => {
  await process(item);
});

Correct

for (const item of items) {
  await process(item);
}

Or parallel:

await Promise.all(items.map(process));

Async Iterators and for-await-of

JavaScript also supports asynchronous iteration, which allows you to work with streams of data that arrive over time rather than all at once.

This pattern is built around Async Iterators, which return promises for each iteration step instead of immediate values.

The for await...of loop allows you to consume these asynchronous sequences in a clean, sequential style.

An async generator function — declared with async function* — is what produces one of these sequences. Like a regular generator, it uses yield to hand back one value at a time instead of returning everything at once; the async keyword just means each yield can also be awaited before the next one runs.

Basic Async Iterator Example

async function* generateNumbers() {
  yield 1;
  yield 2;
  yield 3;
}

for await (const num of generateNumbers()) {
  console.log(num);
}

Each iteration waits for the promise returned by the iterator to resolve before continuing.

Common Use Cases

Async iteration is commonly used with:

  • Streams
  • Large datasets
  • Network responses
  • File processing

For example, in Node.js streams or web APIs that return chunks of data over time.

Example with Fetch Streams

const response = await fetch("/large-data");
const reader = response.body;

for await (const chunk of reader) {
  console.log(chunk);
}

Why Async Iteration Matters

Async iterators allow developers to process data progressively instead of waiting for an entire operation to finish. This can significantly improve performance and memory usage when dealing with large or streaming data sources.

Cancellation and Timeouts

Promises are not cancelable by default.

Using AbortController

Use AbortController to cancel fetches when a user navigates away, preventing memory leaks and unnecessary network usage.

const controller = new AbortController();

fetch(url, { signal: controller.signal });

controller.abort();

The most common real-world use is pairing it with setTimeout to enforce a request timeout:

async function fetchWithTimeout(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error(`Request timed out after ${timeoutMs}ms`);
    }
    throw error;
  } finally {
    clearTimeout(timer);
  }
}

This is essential for:

  • Search inputs
  • Navigation changes
  • Resource cleanup

Error Handling Patterns

Global Error Handling

window.addEventListener("unhandledrejection", (e) => {
  console.error(e.reason);
});

Retry Logic

async function retry(fn, retries = 3, delay = 1000) {
  try {
    return await fn();
  } catch (e) {
    if (retries === 0) throw e;

    // Wait for the specified delay
    await new Promise(resolve => setTimeout(resolve, delay));

    // Recursive call: reduce retry count and double the delay
    return retry(fn, retries - 1, delay * 2);
  }
}

This is a simplified version — see the runnable Exponential Backoff Retry sandbox for the full utility with a capped delay, and Exponential Backoff with Jitter for why randomizing the wait matters once multiple clients retry at the same time.

Sequential vs. Parallel Execution

A common performance mistake is unnecessary sequential await usage. Async/await does not mean sequential-only.

Don't await independent tasks one after another; it slows down your app. Use Promise.all to run them concurrently.

Sequential (Slower)

const a = await getUser(id);
const b = await getSettings(id);

Parallel (Faster)

const [a, b] = await Promise.all([
  getUser(id),
  getSettings(id)
]);

This runs operations in parallel while keeping readable syntax.

Use sequential execution only when one task depends on another.

Comparison Table: Which One to Use?

FeatureCallbacksPromisesAsync/Await
ReadabilityPoor (Nesting)Moderate (Chaining)Excellent (Linear)
Error HandlingManual/Difficult.catch()try/catch
ComplexityHigh for multiple tasksMediumLow

When to Use What

  • Callbacks: Best for low-level APIs (like Node.js's fs module), event listeners (element.addEventListener), or simple one-off timers.
  • Promises: Ideal for library authors, creating composable async logic, or when you need the utility of Promise.all and Promise.race.
  • Async/Await: The default choice for application business logic. It makes complex operations look clean and sequential.

Common Mistakes to Avoid

  • The "Ghost" Promise: Forgetting to await a promise, which results in the code continuing before the task is finished.
  • Silent Failures: Using async functions without a try/catch block, causing unhandled rejections that are difficult to debug.
  • Waterfall Slowness: Accidentally running independent async tasks in a sequence (waterfall) instead of using Promise.all to run them in parallel.
  • The Hybrid Mess: Mixing callbacks and promises inconsistently in the same function, leading to "Inversion of Control" bugs.

Best Practices

  1. Prefer async/await: It results in a much cleaner stack trace and is significantly easier to debug than nested .then() chains.
  2. Avoid forEach for Async: forEach is not promise-aware. It will fire off all your async calls and finish before they resolve. Use for...of if you need to run tasks one-by-one, or .map() with Promise.all for parallel execution.
  3. Handle Every Rejection: Always wrap your await calls in a try/catch block or ensure there is a global unhandledrejection listener for safety.
  4. Beware of Microtask Starvation: Because microtasks (Promises) have VIP priority, a recursive function that constantly adds new microtasks can "starve" the event loop, preventing the UI from rendering or macrotasks (like setTimeout) from ever firing.
  5. Keep the Main Thread Clear: Offload heavy CPU-bound computation (like image processing or complex math) to Web Workers. Asynchronous code handles waiting well, but it doesn't help with heavy lifting on a single thread.

Wrapping Up

Asynchronous JavaScript has evolved to solve real problems:

  • Callbacks enabled non-blocking behavior
  • Promises brought structure and composability
  • Async/await delivered clarity and maintainability

Mastering these concepts allows you to write JavaScript that scales — not just in performance, but in readability and long-term maintainability.

Understanding why async patterns exist is the key to using them correctly. From here, the natural next step is learning how JavaScript reacts to changes in the DOM, viewport, and element size — covered in my companion guide: Mastering JavaScript Observers: A Complete Guide to Reactive Web APIs.

Getting the microtask-vs-macrotask ordering to click as a diagram instead of just a list of rules took a few passes, and building a live Promise.all/allSettled/race/any playground next to the section that explains them felt like the right way to make combinator choice concrete instead of theoretical. Thanks for following the whole callbacks → promises → async/await arc with me; go find an unnecessary sequential await in your own code and fix it. ⏱️

  • Mastering JavaScript Operators: A Complete Guide with Examples

    Transform, compare, assign—write cleaner code.
    A complete breakdown of all JavaScript operators including arithmetic, logical, assignment, comparison, bitwise, and special operators with syntax and real-world examples.
    Published at:
  • Mastering JavaScript Observers: A Complete Guide to Reactive Web APIs

    Unlock reactive power with JavaScript Observer APIs.
    Master MutationObserver, IntersectionObserver, ResizeObserver, and PerformanceObserver. Learn to build high-performance, reactive web apps with real-world examples and best practices.
    Published at:
  • SVG Essentials: Mastering the Path Element

    Understand SVG paths, curves, and commands
    A complete guide to the SVG path element — path commands, coordinates, bézier curves, arcs, compound paths and fill-rule, pathLength-based line-draw animation, CSS's path() function, stroke rendering, and the shorthand tricks behind exported path data.
    Published at: