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.

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
colibrary) 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:
- Built-in executor — no
colibrary needed; async functions run like regular functions. - Better semantics —
asyncandawaitare self-explanatory compared to*andyield. - Better adaptability —
awaitaccepts 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



