JavaScript Zero to Advanced #8: Async JavaScript β Callbacks, Promises, async/await & fetch
Loading data from a server, reading a file, waiting for a timer: these things take time. If JavaScript simply stopped and waited, the whole page would freeze. Instead, JavaScript starts the slow task, keeps running other code, and comes back when the result is ready. This is asynchronous programming, and it is essential for every real app. In this part we will go from callbacks to Promises to async/await, and finish by fetching data from an API.
Synchronous vs asynchronous
Synchronous code runs line by line, and each line waits for the previous one. Asynchronous code schedules work for later. Look at the order of the output:
console.log("1. Order placed")
setTimeout(() => {
console.log("3. Food is ready")
}, 1000)
console.log("2. Waiting for the food...")
Output:
1. Order placed
2. Waiting for the food...
3. Food is ready
setTimeout does not pause the program. It registers a callback to run after 1000 ms and immediately moves on. That is why line 2 prints before line 3. Think of a restaurant: the waiter takes your order and serves other tables instead of standing in the kitchen. We will see exactly how this scheduling works (the event loop) in Part 10.
Callbacks and callback hell
The oldest way to handle async results is to pass a callback that runs when the work is done. To keep examples runnable anywhere, we simulate a slow server with setTimeout:
function getUser(id, callback) {
setTimeout(() => callback({ id, name: "Budi" }), 300)
}
function getOrders(user, callback) {
setTimeout(() => callback(["Coffee", "Bread"]), 300)
}
function getInvoice(orders, callback) {
setTimeout(() => callback({ items: orders.length, total: 40000 }), 300)
}
getUser(1, (user) => {
console.log("User:", user.name)
getOrders(user, (orders) => {
console.log("Orders:", orders.join(", "))
getInvoice(orders, (invoice) => {
console.log("Invoice total:", invoice.total)
})
})
})
Output:
User: Budi
Orders: Coffee, Bread
Invoice total: 40000
It works, but every dependent step adds another level of nesting. Add error handling to each level and you get the infamous callback hell (or "pyramid of doom"): code that is hard to read, hard to change and easy to break.
Promises
A Promise is an object that represents a value that will be available in the future. It is in one of three states: pending, fulfilled (success) or rejected (failure). You create one with new Promise and call resolve or reject when the work finishes:
function checkStock(item, quantity) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (quantity <= 5) {
resolve(`${quantity} x ${item} reserved`)
} else {
reject(new Error(`Only 5 ${item} left`))
}
}, 200)
})
}
checkStock("Coffee", 2)
.then((message) => console.log("Success:", message))
.catch((error) => console.log("Failed:", error.message))
.finally(() => console.log("Check finished"))
Output:
Success: 2 x Coffee reserved
Check finished
then handles success, catch handles errors, and finally runs either way (great for hiding a loading spinner). If we call checkStock("Coffee", 10) instead, we get:
Output:
Failed: Only 5 Coffee left
Check finished
Chaining Promises
The real power is that then returns a new Promise. If you return a Promise from inside then, the next then waits for it. Nesting turns into a flat chain, and one catch handles errors from any step:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const getUser = (id) => wait(300).then(() => ({ id, name: "Budi" }))
const getOrders = (user) => wait(300).then(() => ["Coffee", "Bread"])
getUser(1)
.then((user) => {
console.log("User:", user.name)
return getOrders(user)
})
.then((orders) => {
console.log("Orders:", orders.join(", "))
return orders.length
})
.then((count) => console.log("Order count:", count))
.catch((error) => console.log("Something failed:", error.message))
Output:
User: Budi
Orders: Coffee, Bread
Order count: 2
async/await
async/await is syntax built on top of Promises that lets you write asynchronous code that reads like synchronous code. Mark a function with async, then use await to pause that function until a Promise settles. Errors are handled with a normal try/catch:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const getUser = (id) => wait(300).then(() => ({ id, name: "Siti" }))
const getOrders = (user) => wait(300).then(() => ["Tea", "Cake"])
const pay = (amount) =>
wait(100).then(() => {
if (amount > 100000) throw new Error("Payment limit exceeded")
return "PAID"
})
async function checkout(userId, amount) {
try {
const user = await getUser(userId)
const orders = await getOrders(user)
console.log(`${user.name} ordered ${orders.join(" and ")}`)
const status = await pay(amount)
console.log("Status:", status)
} catch (error) {
console.log("Checkout failed:", error.message)
} finally {
console.log("Done")
}
}
await checkout(1, 50000)
await checkout(1, 250000)
Output:
Siti ordered Tea and Cake
Status: PAID
Done
Siti ordered Tea and Cake
Checkout failed: Payment limit exceeded
Done
An async function always returns a Promise. Here we use top-level await, which works in ES modules (and in the browser console). In a plain script, you would call checkout(1, 50000).then(...) instead.
Running tasks in parallel
Awaiting one task after another is right when each step needs the previous result. When tasks are independent, start them all at once with Promise.all:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const fetchPrice = (item, ms, price) => wait(ms).then(() => ({ item, price }))
let start = Date.now()
const a = await fetchPrice("Coffee", 300, 25000)
const b = await fetchPrice("Bread", 300, 15000)
console.log(`Sequential: ~${Math.round((Date.now() - start) / 100) * 100}ms`, a.price + b.price)
start = Date.now()
const results = await Promise.all([
fetchPrice("Coffee", 300, 25000),
fetchPrice("Bread", 300, 15000)
])
console.log(`Parallel: ~${Math.round((Date.now() - start) / 100) * 100}ms`, results.map((r) => r.item))
Output:
Sequential: ~600ms 40000
Parallel: ~300ms [ 'Coffee', 'Bread' ]
Other helpers are worth knowing:
| Method | Resolves when | Rejects when |
|---|---|---|
Promise.all | all succeed (array of results) | any one fails |
Promise.allSettled | all finish, success or failure | never |
Promise.race | the first one settles with success | the first one settles with failure |
Promise.any | the first one succeeds | all fail |
const tasks = [
Promise.resolve("profile loaded"),
Promise.reject(new Error("avatar not found")),
Promise.resolve("settings loaded")
]
const outcomes = await Promise.allSettled(tasks)
for (const outcome of outcomes) {
console.log(outcome.status, "->", outcome.value ?? outcome.reason.message)
}
Output:
fulfilled -> profile loaded
rejected -> avatar not found
fulfilled -> settings loaded
Fetching data with fetch
fetch is the built-in way to make HTTP requests in browsers and in Node.js 18+. It returns a Promise for a Response. Two important details: fetch only rejects on network failures, not on HTTP errors like 404 or 500, so check response.ok; and reading the body (response.json()) is also asynchronous.
async function loadUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
try {
const user = await loadUser(1)
console.log(`${user.name} <${user.email}>`)
await loadUser(999)
} catch (error) {
console.log("Could not load user:", error.message)
}
Assuming the API returns {"id":1,"name":"Budi","email":"budi@example.com"} for user 1 and a 404 for user 999:
Output:
Budi <budi@example.com>
Could not load user: HTTP 404
To send data, pass an options object with the method, headers and a JSON body:
const response = await fetch("https://api.example.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Hello", author: "Siti" })
})
const created = await response.json()
console.log(response.status, created.title)
If the API answers with 201 Created and echoes the new post back, the output is:
Output:
201 Hello
Common mistakes
- Forgetting
await. You get a Promise instead of the value.console.log(getUser(1))printsPromise { <pending> }in Node.js. - Not handling rejections. Every Promise chain needs a
catch, and everyawaitthat can fail needs atry/catchsomewhere above it. - Using
awaitinsideforEach.forEachdoes not wait for async callbacks. Usefor...offor sequential work orPromise.allwithmapfor parallel work. - Awaiting independent tasks one by one. It makes your app slower for no reason; use
Promise.all. - Assuming fetch throws on 404. It does not. Always check
response.ok.
Exercise
Write a function fetchWithTimeout(url, ms) that rejects with new Error("Timeout") if the request takes longer than ms milliseconds. Hint: combine fetch and a wait-style Promise that rejects, using Promise.race. Then write loadDashboard() that loads /users, /orders and /stats from https://api.example.com in parallel with Promise.allSettled and logs which ones succeeded.
Summary
- Asynchronous code lets JavaScript wait for slow tasks without freezing.
- Callbacks work but nest badly; Promises flatten the flow with
then/catch/finally. async/awaitmakes Promise-based code read like synchronous code, withtry/catchfor errors.- Use
Promise.alland friends to run independent tasks in parallel. fetchreturns a Promise; checkresponse.okandawait response.json().
In Part 9 we will organize code with classes, understand prototypes under the hood, and split programs into ES modules.
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
PHP Zero to Advanced #8: Composer, Namespaces, PSR-4 Autoloading & Exceptions
Structure real PHP projects: organize code with namespaces, manage dependencies with Composer, autoload classes with PSR-4, and handle errors cleanly with exceptions.
Ruby on Rails Zero to Advanced #8: Forms, Strong Parameters & Full CRUD
Build a complete CRUD feature by hand with form_with and strong parameters, then compare it with what rails generate scaffold gives you for free.