Skip to main content

Command Palette

Search for a command to run...

JavaScript Async Programming: From Callbacks to Async/Await

A complete guide to JavaScript's async evolution — from callbacks and events to Promises, generators, and async/await.

Published
4 min read
JavaScript Async Programming: From Callbacks to Async/Await

Originally written in 2023. Content may vary slightly across newer versions.

JavaScript's async story didn't start with async/await — it evolved through several patterns, each solving problems the previous one couldn't. This article traces that evolution from callbacks all the way to async functions.


Synchronous vs. asynchronous

JavaScript is single-threaded — code executes line by line within the call stack. Unless the previous task completes, the next one waits. This is simple to reason about, but if one task runs indefinitely, everything else is blocked and the browser appears frozen.

JavaScript's asynchronous paradigm addresses this. Instead of running in one go, an async task is split into stages with callbacks. HTTP requests are a classic example: once a request is sent, the response won't come back immediately, so a callback handles the result when it does.


Callbacks

The oldest solution. Given two functions f1 and f2 where f2 depends on f1's result:

f1()
f2()

If f1 takes a long time, refactor f2 as f1's callback:

function f1(callback) {
  setTimeout(function () {
    // f1's code
    callback()
  }, 2000)
}

f1(f2)

Pro: Simple to understand and implement. Con: As logic grows, callbacks become deeply nested, hard to read, and tightly coupled.


Event-driven programming

Tasks execute based on whether a specific event fires.

f1.on('done', f2)

function f1() {
  setTimeout(function () {
    // f1's code
    f1.trigger('done')
  }, 2000)
}

Pro: Easy to decouple; works well with modularisation. Con: The overall logic flow becomes harder to follow as the application grows.


Publish/Subscribe

Treat events as signals. A signal centre publishes when a task completes; other tasks subscribe to act on it — also known as the observer pattern.

Using Ben Alman's Tiny Pub/Sub:

jQuery.subscribe('done', f2)

function f1() {
  setTimeout(function () {
    // f1's code
    jQuery.publish('done')
  }, 2000)
}

jQuery.unsubscribe('done', f2)

Pro: Similar to event-driven, but the signal centre gives a clearer picture of what's happening across the application.


Promises

Proposed by CommonJS as a standardised async solution. Every async task returns a Promise with a then method:

f1().then(f2).then(f3)

Rewrite f1 with a deferred:

function f1() {
  var dfd = $.Deferred()
  setTimeout(function () {
    // f1's code
    dfd.resolve()
  }, 2000)
  return dfd.promise()
}

Handle errors:

f1().then(f2).fail(f3)

Pro: Callbacks are chained rather than nested. If a callback is attached after a task completes, it fires immediately — no risk of missing an event.


Generators

Introduced in ES6, generators can pause and resume execution using yield. They make async code read almost like synchronous code:

function* gen() {
  var url = 'https://api.github.com/users/github'
  var result = yield fetch(url)
  console.log(result.bio)
}

Executing it manually:

var g = gen()
var result = g.next()

result.value
  .then(function(data) { return data.json() })
  .then(function(data) { g.next(data) })

Generators also support two-way data flow and external error handling via .throw():

function* gen(x) {
  try {
    var y = yield x + 2
  } catch (e) {
    console.log(e)
  }
  return y
}

var g = gen(1)
g.next()
g.throw('error!') // error!

Con: The two-phase execution pattern can be confusing, and generators require an external executor (like the co library) to run automatically.


Async/Await

Introduced in ES7, async functions are syntactic sugar for generators — and considered the ultimate solution to async programming.

var asyncReadFile = async function () {
  var f1 = await readFile('/etc/fstab')
  var f2 = await readFile('/etc/shells')
  console.log(f1.toString())
  console.log(f2.toString())
}

Three improvements over generators:

  1. Built-in executor — no co library needed; async functions run like regular functions.
  2. Better semanticsasync and await are self-explanatory compared to * and yield.
  3. Better adaptabilityawait accepts both Promises and primitive values.

Async functions always return a Promise. Use try...catch for error handling:

async function main() {
  try {
    const val1 = await firstStep()
    const val2 = await secondStep(val1)
    const val3 = await thirdStep(val1, val2)
    console.log('Final: ', val3)
  } catch (err) {
    console.log(err)
  }
}

Run independent operations in parallel with Promise.all:

// slow — sequential
let foo = await getFoo()
let bar = await getBar()

// fast — parallel
let [foo, bar] = await Promise.all([getFoo(), getBar()])

Summary

Pattern Readability Error handling Coupling
Callbacks Low Manual High
Event-driven Medium Manual Medium
Pub/Sub Medium Manual Low
Promises High .catch() Low
Generators High try/catch Low
Async/Await Highest try/catch Low

References: Async JavaScript · Generator · Async