Asynchronous programming allows a program to start a task that may take some time to complete without stopping the rest of the program from running.
For example, imagine your program needs to download data from a server. The program does not need to sit and wait doing nothing until the server responds. It can continue with other work and handle the response when it arrives.
Synchronous code runs one operation at a time and waits for each operation to finish before moving to the next.
console.log("First"); console.log("Second"); console.log("Third");
Output:
First Second Third
Each statement runs in order.
Asynchronous code can start an operation and continue executing other code while waiting for the operation to complete.
console.log("First"); setTimeout(() => { console.log("Second"); }, 2000); console.log("Third");
Output:
First Third Second
The timer waits for approximately 2 seconds before its callback runs, while JavaScript continues executing the next statement.
JavaScript is synchronous and single-threaded by default.
This means JavaScript executes one piece of JavaScript code at a time on its main thread.
For example:
const a = 10; const b = 20; const c = a + b; console.log(c);
The statements execute in order:
10 + 20 30
JavaScript itself does not normally create additional JavaScript threads for every task.
However, JavaScript can perform asynchronous operations because the environment where JavaScript runs provides additional APIs.
For example:
- Browsers provide Web APIs.
- Node.js provides APIs for files, networking, timers, and other operations.
These environments allow JavaScript to start operations that can finish later.
Many operations take time to complete.
Examples include:
- Downloading data from a server
- Reading a file
- Waiting for a timer
- Sending a network request
- Waiting for a user to click a button
- Accessing a database
If JavaScript had to wait for every slow operation to finish before doing anything else, applications could become unresponsive.
Asynchronous programming allows JavaScript to remain responsive while these operations are being completed.
A callback is a function that is passed to another function so that it can be executed later.
For example:
function greet(name, callback) { console.log(`Hello ${name}`); callback(); } function finished() { console.log("Greeting finished."); } greet("Moses", finished);
Output:
Hello Moses Greeting finished.
Here:
finished
is passed as an argument to greet().
The greet() function later calls it:
callback();
Therefore, finished is a callback function.
Callbacks are commonly used with browser events.
For example:
const button = document.getElementById("button"); button.addEventListener("click", () => { console.log("Button clicked!"); });
The function:
() => { console.log("Button clicked!"); }
is a callback.
It is not executed immediately.
Instead, the browser executes it when the user clicks the button.
window.addEventListener("load", () => { console.log("The page has finished loading."); });
The callback runs after the page's load event occurs.
Callbacks are also used with timers.
setTimeout(() => { console.log("2 seconds have passed."); }, 2000);
The second argument:
2000
means approximately 2000 milliseconds, or 2 seconds.
The function:
() => { console.log("2 seconds have passed."); }
is the callback.
The callback is executed after the timer has finished.
Callbacks can also be used with network operations.
For example, the older XMLHttpRequest API uses callbacks:
const xhr = new XMLHttpRequest(); xhr.onreadystatechange = () => { if (xhr.readyState === 4) { if (xhr.status === 200) { console.log(xhr.responseText); } else { console.error("Request failed."); } } }; xhr.open("GET", "https://example.com/data"); xhr.send();
The request takes time to complete.
Instead of stopping the entire program while waiting, JavaScript continues running and later executes the callback when the request state changes.
Node.js traditionally uses a pattern called an error-first callback.
The callback normally receives the error as its first argument.
Example:
const fs = require("node:fs"); fs.readFile("file.txt", (err, data) => { if (err) { console.error("Error:", err); return; } console.log(data.toString()); });
The general structure is:
function callback(err, data) { // handle result }
If an error occurs:
err
contains information about the error.
If the operation succeeds, err is usually null.
This pattern makes it possible to handle both successful and unsuccessful operations.
Callbacks are useful, but using many callbacks inside one another can make code difficult to read and maintain.
For example:
window.addEventListener("load", () => { document.getElementById("button").addEventListener("click", () => { setTimeout(() => { items.forEach((item) => { console.log(item); }); }, 2000); }); });
Notice how the code becomes increasingly indented.
With more asynchronous operations, this can become much worse:
doSomething((result1) => { doSomethingElse(result1, (result2) => { doAnotherThing(result2, (result3) => { doSomethingAgain(result3, (result4) => { console.log(result4); }); }); }); });
This pattern is often called callback hell or the pyramid of doom.
The main problems are:
- Difficult-to-read code
- Deep nesting
- Difficult error handling
- Difficult maintenance
- Difficult debugging
A function that accepts another function as an argument or returns a function is called a higher-order function.
For example:
function processUser(name, callback) { console.log(`Processing ${name}...`); callback(); } processUser("Moses", () => { console.log("User processed."); });
Here:
processUser()is a higher-order function.- The function passed to
processUser()is a callback.
JavaScript supports this because functions are first-class values.
This means functions can be:
- Stored in variables
- Passed as arguments
- Returned from other functions
- Stored in objects or arrays
A simplified way to understand asynchronous JavaScript is:
JavaScript Code ↓ Call Stack ↓ Web APIs / Node.js APIs ↓ Task completes ↓ Callback Queue ↓ Event Loop ↓ Call Stack ↓ Callback executes
For example:
console.log("Start"); setTimeout(() => { console.log("Timer finished"); }, 2000); console.log("End");
The output is:
Start End Timer finished
The important idea is that JavaScript does not stop executing the entire program while the timer is waiting.
The event loop is a mechanism that helps JavaScript handle asynchronous operations while JavaScript itself remains single-threaded.
A simplified process is:
- JavaScript executes code on the call stack.
- An asynchronous operation is started.
- The environment handles the operation.
- JavaScript continues executing other code.
- When the operation is ready, its callback is placed into an appropriate queue.
- The event loop checks whether the call stack is empty.
- The callback is eventually moved to the call stack.
- JavaScript executes the callback.
Understanding the call stack, queues, and event loop is important for understanding asynchronous JavaScript.
Modern JavaScript provides better ways to organize asynchronous operations.
The main approaches are:
setTimeout(() => { console.log("Finished"); }, 1000);
const promise = new Promise((resolve, reject) => { resolve("Success!"); }); promise.then((result) => { console.log(result); });
async function run() { const result = await promise; console.log(result); } run();
Promises were introduced in ES2015 (ES6), while async/await was introduced in ES2017.
| Term | Meaning |
|---|---|
| Synchronous | Operations execute in order and wait for each other |
| Asynchronous | Operations can complete later without blocking the current flow |
| Callback | A function passed to another function to be executed later |
| Higher-order function | A function that accepts or returns another function |
| Call Stack | Keeps track of currently executing JavaScript functions |
| Event Loop | Coordinates asynchronous callbacks with the call stack |
| Promise | Represents the eventual result of an asynchronous operation |
| async | Declares a function that works with promises |
| await | Waits for a promise to settle inside an async function |
| Callback Hell | Excessive nesting of callbacks |
Think of ordering food at a restaurant.
You order food and stand at the kitchen waiting until the food is prepared.
Order food ↓ Wait ↓ Food ready ↓ Continue
You order food, receive a number, and sit down.
Order food ↓ Sit and do something else ↓ Food becomes ready ↓ You are notified ↓ Collect food
The notification is similar to a callback.
JavaScript executes JavaScript code synchronously on a single main thread, but it can handle asynchronous operations through capabilities provided by its host environment, such as browsers and Node.js.
Callbacks were one of the original and simplest ways to work with asynchronous operations.
However, too many nested callbacks can make code difficult to understand and maintain. Modern JavaScript therefore provides Promises and async/await, which make asynchronous code easier to organize.
Callbacks ↓ Promises ↓ Async / Await
Understanding callbacks is important because they form the foundation for understanding how JavaScript handles asynchronous programming.