Skip to content

Asynchronous JavaScript

Asynchronous JavaScript lets your app keep running while it waits for slow tasks, like API calls, file operations, or timers. Without async patterns like this, the UI would freeze, and users would have to wait for every single task to finish before they could do anything else.

console.log("A");
console.log("B");
console.log("C");

Here, each line waits for the line before it to finish first.

graph TD A[Start] --> B[Run Sync Code] B --> C[Async Task Sent to Web APIs] C --> D[Callback Queue] D --> E[Event Loop Moves Task to Call Stack] E --> F[Callback Executes]

A callback is simply a function that you pass into another function, which gets run later on.

function getData(callback) {
setTimeout(() => {
callback("Data loaded");
}, 1000);
}
getData((result) => {
console.log(result);
});

Callbacks are simple and still useful, but when you nest a lot of them inside each other, the code becomes hard to read.

Callback hell happens when many async steps depend on each other, one after another, and end up creating a lot of deep nesting.

loginUser((user) => {
getProfile(user.id, (profile) => {
getOrders(profile.id, (orders) => {
getInvoice(orders[0].id, (invoice) => {
console.log(invoice);
});
});
});
});
graph TD A[loginUser] --> B[getProfile] B --> C[getOrders] C --> D[getInvoice] D --> E[Deep Nesting and Hard Debugging]

A Promise stands for a value that will be available now, later, or maybe never at all. A promise can be in three possible states:

pending

This is the starting state, meaning the task is still running.

fulfilled

This means the task finished successfully.

rejected

This means the task failed.

graph LR A[pending] -->|resolve| B[fulfilled] A -->|reject| C[rejected]
const wait = (ms) => {
return new Promise((resolve, reject) => {
if (typeof ms !== "number") {
reject(new Error("ms must be a number"));
return;
}
setTimeout(() => {
resolve(`Done in ${ms}ms`);
}, ms);
});
};

resolve marks the task as successful and passes the result forward. reject marks the task as failed and passes an error forward instead.

wait(500)
.then((result) => {
console.log(result);
})
.catch((error) => {
console.error(error.message);
})
.finally(() => {
console.log("Always runs");
});
getUser()
.then((user) => getOrders(user.id))
.then((orders) => getInvoice(orders[0].id))
.then((invoice) => console.log(invoice))
.catch((error) => console.error("Flow failed:", error));

Chaining lets you write your async steps one after another in a straight line, instead of nesting them deeply inside each other like with callbacks.

async and await are special syntax built on top of promises. They let your async code look much closer to normal, synchronous code.

async function loadDashboard() {
try {
const user = await getUser();
const orders = await getOrders(user.id);
console.log(orders);
} catch (error) {
console.error("Could not load dashboard", error);
}
}
async function createPost() {
const response = await fetch("https://example.com/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer your-token"
},
body: JSON.stringify({
title: "Hello",
content: "Learning async deeply"
})
});
const responseHeaders = response.headers;
const contentType = responseHeaders.get("content-type");
const data = await response.json();
console.log("Response content-type:", contentType);
console.log("Response body:", data);
}

Reading Request Headers and Body on Server

Section titled “Reading Request Headers and Body on Server”
app.post("/api/posts", express.json(), (req, res) => {
console.log("Auth header:", req.headers.authorization);
console.log("Request body:", req.body);
res.setHeader("x-app-version", "1.0.0");
res.json({ ok: true, received: req.body });
});
const xhr = new XMLHttpRequest();
xhr.open("POST", "https://example.com/api/posts");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.setRequestHeader("Authorization", "Bearer your-token");
xhr.onload = function () {
console.log("Status:", xhr.status);
console.log("Response headers:\n", xhr.getAllResponseHeaders());
console.log("Response body:", xhr.responseText);
};
xhr.onerror = function () {
console.error("Network error");
};
xhr.send(JSON.stringify({ title: "Hello", content: "Sent via XHR" }));

fetch is usually cleaner to use, since it is built around promises. XHR is older, but you will still come across it in some older (legacy) code.

try {
const response = await fetch("/api/data");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Request failed:", error.message);
}
fetch("/api/data")
.then((res) => {
if (!res.ok) throw new Error("Bad response");
return res.json();
})
.catch((error) => {
console.error(error.message);
});
class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
graph TD A[Start Request] --> B[Send Headers and Body] B --> C[Server Processes] C --> D[Receive Response Headers] D --> E[Parse Response Body] E --> F{Success?} F -->|Yes| G[Update UI] F -->|No| H[Handle Error]
  1. Use async/await to keep complicated flows easy to read.
  2. Wrap each logical async step in its own try/catch.
  3. Always check the response status before trying to read the body.
  4. Send clear headers, like Content-Type and your auth tokens.
  5. Show error messages that make sense to the user, not raw technical stack traces.