Skip to content
JavaScript js async 4 min read

Asynchronous JavaScript

JavaScript runs your code on a single thread — one line at a time, one thing at a time. Yet the programs we build constantly wait on slow operations: network requests, file reads, timers, and user clicks. If the language blocked the thread every time it waited, the page would freeze and the server would stall. Asynchronous JavaScript is how a single-threaded runtime stays responsive: it starts slow work, keeps running, and reacts when the result arrives. This page frames the problem and traces the evolution from callbacks to promises to async/await that the rest of this section explores in depth.

One thread, many waits

There is exactly one call stack in a JavaScript runtime. Whatever is on top runs to completion before anything else can run — there is no preemption mid-function. This is what makes JS easy to reason about (no data races on shared variables) but also what makes blocking dangerous.

Consider a synchronous read that takes two seconds. While it runs, nothing else can: no clicks register, no animations advance, no other request is served.

// Synchronous (blocking) — DO NOT do this for slow work
const data = readFileSyncSomehow("big.json"); // thread frozen for 2s
console.log("parsed", data.length);
console.log("this line waits its turn");

The fix is to not wait on the thread. Instead, hand the slow work to the environment (the browser or Node.js), register what to do when it finishes, and let the thread move on.

console.log("start");

setTimeout(() => console.log("slow work done"), 2000);

console.log("end");

Output:

start
end
slow work done

setTimeout returns immediately. The callback is parked outside the engine and only runs once the stack is empty and the timer has elapsed — the essence of non-blocking behavior.

Where async work actually happens

The JavaScript engine itself (V8, SpiderMonkey) does not implement timers, networking, or disk I/O. Those live in the host environment and run on separate threads or kernel facilities. The engine just schedules callbacks back onto its single thread when results are ready, coordinated by the event loop.

Source of async workBrowser APINode.js API
TimerssetTimeout, setIntervalsame, plus setImmediate
Networkfetch, XMLHttpRequestfetch, http, https
User / DOM eventsaddEventListener
File / disk I/OFile System Access APIfs.promises
MicrotasksPromise, queueMicrotasksame

Key idea: “Async” does not mean “parallel JavaScript.” Your code still runs on one thread — the waiting is delegated, not the executing. Two console.log calls never overlap.

The journey: callbacks to async/await

Async JavaScript has gone through three eras. Each solves a real pain point of the one before.

1. Callbacks — pass a function to run when the work finishes. Simple, but nesting dependent operations produces deeply indented “callback hell” with scattered error handling.

getUser(1, (err, user) => {
  if (err) return handle(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handle(err);
    getTotal(orders, (err, total) => {
      if (err) return handle(err);
      console.log(total);
    });
  });
});

2. Promises — a Promise is an object representing a value that will exist eventually. It can be pending, fulfilled, or rejected, and you chain .then() / .catch() to flatten the nesting and centralize errors.

getUser(1)
  .then((user) => getOrders(user.id))
  .then((orders) => getTotal(orders))
  .then((total) => console.log(total))
  .catch(handle);

3. async/await — syntactic sugar over promises that lets asynchronous code read like synchronous code. await pauses the function (not the thread) until a promise settles, and ordinary try/catch handles errors.

async function showTotal() {
  try {
    const user = await getUser(1);
    const orders = await getOrders(user.id);
    const total = await getTotal(orders);
    console.log(total);
  } catch (err) {
    handle(err);
  }
}

All three describe the same underlying machinery. async/await is the modern default, but promises and even callbacks still appear in libraries and older APIs, so it pays to understand the whole chain.

A mental model of the loop

When async work finishes, its callback doesn’t run instantly — it joins a queue and waits for the current stack to clear. Roughly:

        ┌─────────────┐
        │  Call Stack │  ← runs your code, one frame at a time
        └─────────────┘

              │ event loop pulls next task when stack is empty
   ┌──────────┴───────────┐
   │  Microtask queue     │  (Promise callbacks) — drained first
   ├──────────────────────┤
   │  Macrotask queue     │  (timers, I/O, events)
   └──────────────────────┘

This ordering — microtasks before the next macrotask — explains many “why did this log out of order?” surprises, covered in the event loop page.

Best practices

  • Reach for async/await by default; drop to raw promises only for combinators like Promise.all.
  • Never block the thread with synchronous I/O or long CPU loops in request paths or UI handlers.
  • Always handle rejection — an unhandled promise rejection is a real bug, not a warning to ignore.
  • Remember that await pauses one function, not the whole program; independent work can still overlap with Promise.all.
  • Treat anything touching the network, disk, or a timer as async, and design APIs to return promises.
  • Don’t mix callback and promise styles for the same operation; pick one and wrap legacy callbacks with new Promise.
Last updated June 1, 2026
Was this helpful?