Async JavaScript — from callbacks to awaiting the future

How async JS actually grew—from “call me back” to Promises and async/await—and what matters today when you cancel requests, compose work, or wonder why timers run in a funny order.

Back

Your UI thread (browser tab or typical Node workload) basically has one crowded lane at a time. If you sit there spinning while a disk read or HTTP response trickles in, everyone else waits too—interaction jank, frozen terminal, grim vibes.

That’s why “async” isn’t a fad library—it’s JavaScript cooperating with time and I/O instead of brute-forcing the world to pretend everything is instantaneous.

Below is less a chronological museum tour, more “here’s the story people actually tell each other”—with enough depth that the examples aren’t pretending.


#Call stack vs “later”: the bit that saves your sanity

When JS runs synchronously, you’re marching up and down the call stack. Anything deferred—timers, network completion, queued Promise reactions—eventually runs when the stack drains and other machinery hands work back into the VM.

Rough split people actually use in their heads:

  • Macrotasks — timers, lots of browser/Node scheduling. One batch per turn of the event loop, after the stack’s clear.
  • Microtasks — Promise reactions, queueMicrotask, etc. They run after the current synchronous chunk finishes, before the next macrotask, and the engine keeps draining them until the queue is empty.

That’s why you can schedule setTimeout(..., 0) and still see Promise callbacks jump ahead—microtasks politely cut the line ahead of timers that “felt” synchronous when you queued them.

Play with it:

console.log('1');
 
queueMicrotask(() => console.log('3 — microtask'));
 
setTimeout(() => console.log('4 — macrotask'), 0);
 
console.log('2');
// 1 → 2 → 3 — microtask → 4 — macrotask

You don’t have to memorize every scheduling edge case in every host. What helps is remembering await and .then schedule continuations through the microtask path—that’s usually enough intuition when timers & Promises behave “out of order” at first glance.


#Era one — callbacks, or “remember to call Mom back”

The oldest trick in the book: finish up, then invoke whatever function we gave you.

setTimeout, old-school XMLHttpRequest handlers, legacy Node fs.readFile signatures—it’s continuation-passing all the way down.

Straightforward vibe:

function fetchUser(cb) {
  setTimeout(() => cb(null, { id: 1, name: 'Ada' }), 10);
}
 
fetchUser((err, user) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(user.name);
});

If you bounced around Node awhile, (err, data) err-first callbacks showed up everywhere—errors travel as data so you wire plumbing once instead of inventing seventeen exception styles across modules.

Eventually big trees of callbacks leaned sideways:

// Nested pyramid: each indentation is a sigh
stepA((e1, a) => {
  if (e1) return handle(e1);
  stepB(a, (e2, b) => {
    if (e2) return handle(e2);
    stepC(b, handle);
  });
});

Libraries like async.js softened the choreography. Languages eventually leaned into Promises so eventual values behaved like ordinary returnable objects.


#Era two — Promises: “here’s something you can pass around without losing the plot”

A Promise is one future handshake: fulfilled with a value, or rejected with a reason—and it’s chainable baggage you actually hand to other functions instead of juggling anonymous nested callbacks everywhere.

Spinner example:

function delayedDouble(n, ms = 20) {
  return new Promise((resolve, reject) => {
    if (typeof n !== 'number') {
      reject(new TypeError('expected number'));
      return;
    }
    setTimeout(() => resolve(n * 2), ms);
  });
}

The executor runs immediately, but reacting to the outcome still flows through asynchronous scheduling—meaning resolve(1) still won’t eagerly run downstream handlers until the synchronous stack settles.

Chains:

delayedDouble(3)
  .then((x) => String(x))
  .then(console.log); // prints "6" after microtasks catch up

.catch(handler) reads nicer than juggling onRejected positions—save it around trust boundaries rather than drowning every .then() in branching error lambdas.

Sequentially (before we preach await):

async function sequential() {
  const a = await delayedDouble(1); // 2
  const b = await delayedDouble(a); // 4
  return b;
}

(Under the hood you could write the same thing with chained .then—sometimes that’s clearer; usually await wins hearts.)


#Era three — async/await: read top-to-bottom, still Promise guts

Stick async in front—you always hand back a Promise, even when the body literally returns a plain object.

Use await on anything Promises-ish (or primitives—non-thenables just resolve immediately):

async function fetchUserRecord(id) {
  return fetch(`/users/${id}`).then((r) => r.json());
}
 
async function summarize(ids) {
  const results = [];
  for (const id of ids) {
    results.push(await fetchUserRecord(id)); // waits each step—independent work should batch differently!
  }
  return results;
}

async/await is mostly sugar over .then chains but—crucially—you get try/catch that behaves like mortal humans expect:

async function guarded() {
  try {
    const text = await Promise.resolve('{ "version": 42 }'); // swap with fetch(...).then(r => r.text())
    const data = JSON.parse(text);
    return data.version;
  } catch (err) {
    console.error('parse or fetch failed:', err);
    throw err;
  }
}

If asyncSomething() rejects and nobody awaits it or attaches .catch, browsers/Node yell about UnhandledPromiseRejection. Treat exported async functions like any promise-returning API—someone up top owes the rejection a hug.


#Parallelism helpers—pick verbs that match emotional reality

Often you crave overlap, not a polite queue doing one step at a time forever.

#Promise.all

Everyone must succeed—or the whole gang fails fast with the first rejection.

const endpoints = ['/api/a.json', '/api/b.json', '/api/c.json'];
const users = await Promise.all(endpoints.map((url) => fetch(url).then((r) => r.json())));

Use when partial data is worthless (sharded configs, multi-part permissions, etc).

#Promise.allSettled

Nobody throws at the umbrella level—you get { status: 'fulfilled' | 'rejected', … } per limb.

const outcomes = await Promise.allSettled(urls.map((u) => fetch(u)));
const ok = outcomes.filter((o) => o.status === 'fulfilled');

Great when dashboards need honesty about what broke where.

#Promise.race

First settled handshake wins—often framed as timeouts:

function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('timeout')), ms)
    ),
  ]);
}

#Promise.any

First successful fulfillment survives; everybody flunking aggregates into AggregateError.

const mirrors = ['/m1/item', '/m2/item', '/m3/item'];
 
try {
  const res = await Promise.any(mirrors.map((url) => fetch(url)));
  const body = await res.json();
  console.log('first mirror that behaved', body);
} catch (e) {
  if (e instanceof AggregateError) {
    console.error('every mirror failed', e.errors);
  }
}

Pick the helper by answering “can half of these fail gracefully?”—don’t memorize them alphabetically because the docs rhyme.


#Cancellation — promises don’t “stop,” APIs learn to cooperate

Languages don’t brute-cancel arbitrary Promises. Instead we thread AbortSignal through friendly APIs fetch so user clicks or route changes politely tell work “actually never mind.”

const controller = new AbortController();
 
const p = fetch('/api/data', { signal: controller.signal });
 
// Later: supersede or abandon
controller.abort();
 
try {
  await p;
} catch (e) {
  if (e.name === 'AbortError') {
    // expected—we asked for this
  } else {
    throw e;
  }
}

Practical groove: roughly one AbortController per action (“search box query,” “wizard step”) and propagate signal through helpers or loops whenever you iterate long-ish work—check signal.aborted generously.

Modern browser + Node fetch story lives here when people ask “how do I actually cancel networking?”


#Async iteration—for when bytes drip instead of dump

Sometimes data trickles (streams, chunked bodies). for await...of lines up neatly:

async function* rangeAsync(n) {
  for (let i = 0; i < n; i++) {
    await new Promise((r) => setTimeout(r, 10));
    yield i;
  }
}
 
for await (const n of rangeAsync(3)) {
  console.log(n);
}

Browsers’ ReadableStream, Node’s web-aligned streams—they fit this mental model wherever support lands. Saves you from buffering the internet into RAM unless you genuinely need everything upfront.


#Top-level await in modules

ES modules—not classic "use script" lumps—sometimes let you await right at module top, which means importer graphs wait until that settles before grabbing exports:

// config.mjs
export const settings = await fetch('/config.json').then((r) => r.json());

Handy for bootstrap flags; don’t casually block startup hotpaths when lazy-loading would keep first paint humane.


#Where we landed (late 2020s vibes)

Honestly? async/await won readability. Native Promises became the lingua franca of “eventual outcomes.” fetch plus AbortSignal finally gave browser folks and Node newcomers a coherent HTTP story—you’re not patching twelve HTTP client personalities just to cancel.

We think more consciously about boundaries: scope asynchronous work, cancel with signals, choose Promise helpers by failure semantics. That’s adulthood.

Stuff we still stumble over:

Sprinkling async on helpers that could stay synchronous because “maybe later”—every innocent async allocates a promise whether you awaited or not.

Doing await inside for loops when work was independent—you probably wanted Promise.all / allSettled (after measuring—sometimes sequential really is required).

Ignoring rejections bubbling out of dangling Promises—we’ve all pasted async fireworks into React without attaching error boundaries or .catch; runtimes yell for a reason.

Bits still shimmy—proposals around ergonomic resource teardown, smarter scheduling knobs—but microtasks vs macro tasks fundamentals haven’t vanished.


#Wrapping gently

Think of asynchronous JS history as stacked intentions: callbacks said “tell me later,” Promises said “here’s a token for later,” async/await whispered read books top-to-bottom again, AbortSignal + combinators asked who gets to veto or race whom.

Engines keep threads friendly; humans decide ordering, failure semantics, overlaps. Nail that—even roughly—and async stops feeling like chaos magic. It starts feeling like the language finally matches how networked life actually flows: messy, concurrent, but intelligible if you name what you’re waiting for.

Thanks for reading—now go await something worth the pause.

Written by

Manish Singh

At

Wed Apr 29 2026