Loading a file, waiting for a timer or fetching data from a server all take time. JavaScript does not stop and wait for them: it starts the work, carries on, and runs a function you supply when the result is ready. This lesson explains that model, the callbacks it was built on, and the problems that made the language add promises.
One thread, no waiting
JavaScript runs your code on a single thread. It can only do one thing at a time. If a line of code took three seconds, nothing else could happen for those three seconds: no clicks, no scrolling, no repainting.
You can see this by writing a deliberately slow loop:
function blockFor(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {
// busy-wait: do nothing, very fast, over and over
}
}
console.log("Before");
blockFor(3000);
console.log("After"); // appears three seconds later
Run it in the browser console on any page and try to click or select text while it runs. The page freezes. That is blocking code.
Slow operations such as network requests and timers are handled differently. The browser (or Node.js) does the waiting outside your JavaScript, and your code keeps running. When the work finishes, a function you passed in is queued to run. That is non-blocking, or asynchronous, code.
console.log("1: start");
setTimeout(() => {
console.log("3: timer finished");
}, 0);
console.log("2: end");
// → 1: start
// → 2: end
// → 3: timer finished
Even with a delay of 0, the timer's function runs last. setTimeout only schedules it. The function can run once the code that is running now has finished. Lesson 42 explains exactly how the engine decides that order with the event loop.
Callbacks
A callback is a function you pass to another function, which calls it for you. You have already used plenty:
const numbers = [3, 1, 2];
numbers.forEach((n) => console.log(n * 10));
// → 30
// → 10
// → 20
const button = document.querySelector("button");
button.addEventListener("click", () => console.log("clicked"));
The two callbacks above behave differently:
| Callback | When it runs |
|---|---|
forEach((n) => ...) | Synchronously: immediately, before forEach returns |
addEventListener("click", ...) | Asynchronously: later, whenever a click happens |
Both are callbacks. What makes code asynchronous is not the callback itself but when it is called.
Writing an asynchronous function with a callback
Before promises, asynchronous functions took a callback as their last argument. Here is a small function that pretends to load a course from a server, using setTimeout to simulate network delay:
const courses = [
{ id: 1, title: "JavaScript Basics", authorId: 7 },
{ id: 2, title: "CSS Grid in Practice", authorId: 9 },
];
function loadCourse(id, callback) {
setTimeout(() => {
const course = courses.find((c) => c.id === id);
callback(course);
}, 500);
}
loadCourse(1, (course) => {
console.log(course.title); // → JavaScript Basics (after half a second)
});
console.log("Loading..."); // → Loading... (this logs first)
The result cannot be returned, because loadCourse has already returned by the time the course is found. It has to be passed forward into the callback.
Handling errors: the error-first convention
What if the course does not exist? A try...catch around the call cannot help:
try {
loadCourse(99, (course) => {
console.log(course.title); // throws a TypeError: course is undefined
});
} catch (error) {
console.log("Never reached");
}
By the time the callback runs, the try block has long finished. The error escapes and appears as an uncaught error in the console.
Node.js settled on a convention for this: the error-first callback. The first argument is an error, or null if all went well; the result comes second.
function loadCourse(id, callback) {
setTimeout(() => {
const course = courses.find((c) => c.id === id);
if (!course) {
callback(new Error(`No course with id ${id}`));
return;
}
callback(null, course);
}, 500);
}
loadCourse(99, (error, course) => {
if (error) {
console.error(error.message); // → No course with id 99
return;
}
console.log(course.title);
});
You will still meet this style in older Node.js code. The built-in fs module's callback API uses it:
// read-config.mjs (run with: node read-config.mjs)
import { readFile } from "node:fs";
readFile("config.json", "utf8", (error, text) => {
if (error) {
console.error("Could not read config:", error.message);
return;
}
console.log(text);
});
The .mjs extension tells Node.js the file is an ES module, so import works; lesson 34 covers modules. Modern Node.js code uses the promise version from node:fs/promises instead, which you will see in the next two lessons. Working with files on the server belongs to a Node.js course; here it only illustrates the pattern.
The problems with callbacks
Callbacks work, but they become painful as soon as one step depends on another.
Nesting: the pyramid of doom
Suppose you need to load a course, then its author, then the author's other courses. Each step needs the previous result, so each callback goes inside the last:
loadCourse(1, (error, course) => {
if (error) return showError(error);
loadAuthor(course.authorId, (error, author) => {
if (error) return showError(error);
loadCoursesBy(author.id, (error, otherCourses) => {
if (error) return showError(error);
render(course, author, otherCourses);
});
});
});
Three steps, three levels of indentation, three identical error checks. Real code with five or six steps, loops and conditions becomes very hard to read and change. This shape is often called callback hell or the pyramid of doom.
Error handling is easy to forget
Every callback must check error itself. Forget one check and a failure turns into a confusing TypeError further down, or into silence.
You hand over control
When you pass a callback to someone else's function, you trust it to call your callback:
- exactly once, not twice and not never;
- always asynchronously, or always synchronously, but not sometimes one and sometimes the other;
- with the arguments in the documented order.
Nothing in the language enforces any of this. A buggy library that calls your payment callback twice charges the customer twice. This is sometimes called inversion of control: the flow of your program is in someone else's hands.
Running things in parallel is awkward
Loading three independent courses at once and waiting for all of them means writing your own counter:
const results = [];
let finished = 0;
[1, 2, 3].forEach((id, index) => {
loadCourse(id, (error, course) => {
results[index] = error ? null : course;
finished += 1;
if (finished === 3) console.log("All done", results);
});
});
It works, but every project wrote its own version, each with its own bugs.
What came next
Promises, added to the language in ES2015, solve these problems. A promise is an object that stands for a future result. Instead of passing a callback in, the function hands a promise back, and you decide what to do with it. Promises:
- can only settle once, so a callback cannot fire twice;
- always run their handlers asynchronously;
- pass errors down a chain to one handler;
- come with built-in tools for running work in parallel.
Callbacks do not disappear. Event listeners, array methods and setTimeout all still take callbacks, and that is fine: they are the right tool for "run this every time something happens". Promises are for "give me this one result when it is ready".
Summary
- JavaScript runs on one thread. Long-running code blocks the page.
- Asynchronous operations hand the waiting to the browser or Node.js and call your function when they finish.
- A callback is a function passed to another function. It can be called synchronously (
forEach) or asynchronously (setTimeout, event listeners). - An asynchronous function cannot return its result; it passes the result to a callback.
try...catchcannot catch errors thrown later inside a callback. Node.js uses error-first callbacks instead.- Dependent steps nest into a pyramid, error checks are repeated, and you must trust the callee to call you correctly.
- Promises, covered in the next lesson, were designed to fix exactly these problems.