Skip to content

Handling Delayed Operations in JavaScript

In modern JavaScript development, handling delayed or asynchronous operations, such as API calls or timers, is a critical skill. JavaScript provides various mechanisms to handle such operations, evolving from callbacks to promises, and eventually to the cleaner and more concise async/await syntax. This post explains the differences between synchronous and asynchronous programming and dives deep into handling async operations with callbacks, promises, and async/await.


Before diving into the specifics of handling asynchronous operations, it’s crucial to understand the fundamental difference between synchronous and asynchronous execution.

  • In synchronous code, tasks are executed one at a time, in order. Each task must be completed before moving on to the next.
  • If one task takes a long time (e.g., fetching data over the network), the entire program is blocked, waiting for that task to finish.

Example:

console.log("Step 1");
console.log("Step 2");
console.log("Step 3"); // Output: Step 1, Step 2, Step 3
  • In asynchronous code, some tasks can start and then continue running in the background, allowing the program to proceed without waiting for their completion.
  • This is particularly useful for time-intensive tasks, such as API calls or file reads.

Example:

console.log("Step 1");
setTimeout(() => {
console.log("Step 2 (after 2 seconds)");
}, 2000);
console.log("Step 3"); // Output: Step 1, Step 3, Step 2 (after 2 seconds)

The first method developers used to handle asynchronous operations was callbacks. A callback is a function passed as an argument to another function, executed after an asynchronous operation is completed.

function fetchData(callback) {
setTimeout(() => {
callback("Data fetched successfully!");
}, 2000);
}
fetchData((message) => {
console.log(message); // Output (after 2 seconds): Data fetched successfully!
});

While callbacks work, they have a significant downside: Callback Hell.

When multiple asynchronous operations rely on one another and callbacks are nested, the code becomes deeply indented, hard to read, and even harder to debug.

Example:

doSomething((result1) => {
doAnotherThing(result1, (result2) => {
doYetAnotherThing(result2, (result3) => {
console.log(result3); // Proceed after many nested callbacks
});
});
});

This pyramid-like structure is difficult to manage, especially as the logic becomes more complex.


Promises: A Better Asynchronous Handling Mechanism

Section titled “Promises: A Better Asynchronous Handling Mechanism”

To overcome the challenges of callbacks, ES6 introduced Promises, which represent a value that will be available in the future. A promise can be in one of the following states:

  1. Pending: The asynchronous operation is still in progress.
  2. Fulfilled: The asynchronous operation succeeded.
  3. Rejected: The asynchronous operation failed.

Promises are created using the new Promise constructor.

const fetchData = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = true; // Simulate success/failure
if (success) {
resolve("Data fetched successfully!");
} else {
reject("Error fetching data!");
}
}, 2000);
});
};

Promises can be used by chaining .then() for successful operations and .catch() for errors.

fetchData()
.then((message) => {
console.log(message); // Output (after 2 seconds): Data fetched successfully!
})
.catch((error) => {
console.error(error);
});

Promises make the code less prone to nesting and easier to read.


Building on promises, ES8 introduced async and await, making asynchronous code look and act more like synchronous code. This syntax simplifies working with promises and improves code readability.

  1. async declares a function as asynchronous.
  2. Within an async function, await pauses the execution until a promise is resolved or rejected.

Example:

const fetchData = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data fetched successfully!");
}, 2000);
});
};
const fetchAsync = async () => {
try {
const message = await fetchData(); // Waits for the promise to resolve
console.log(message); // Output (after 2 seconds): Data fetched successfully!
} catch (error) {
console.error(error); // Catches any errors
}
};
fetchAsync();
  • Cleaner Syntax: Makes async code look synchronous and easier to follow.
  • Error Handling: Use try/catch blocks to handle errors, avoiding .catch() chaining.

JavaScript offers multiple options for handling asynchronous operations, each building on its predecessor for improved readability and scalability:

  • Callbacks are simple but can lead to messy, hard-to-read code (callback hell).
  • Promises improve code structuring by eliminating nesting.
  • Async/Await provides the cleanest syntax, making async operations more readable and intuitive.