Understanding Synchronous and Asynchronous JavaScript (With Real-Life Examples)

Himanshu RanaHimanshu Rana
6 min read

Have you ever wondered why JavaScript, despite being single-threaded, feels so responsive? The secret lies in its asynchronous capabilities. But before we get there, let’s first understand what synchronous JavaScript is — and why it sometimes doesn’t make sense for certain types of tasks.


🔁 What is Synchronous JavaScript?

Synchronous JavaScript means that code is executed line by line, in the exact order it’s written. Each operation waits for the previous one to finish before it can start. This is called blocking behavior.

Now, consider this: In a program, there are usually two types of operations:

  1. Memory-based computations (like mathematical operations or variable assignments)

  2. Input/Output operations (like fetching data from a server, reading a file, or waiting for a user to click a button)

The second category — I/O operations — usually takes much more time. So if JavaScript waits for each of these operations to finish before moving to the next one, the user experience can feel sluggish.

Let’s break that down with a simple analogy.


🧼 A Real-Life Analogy: Washing Clothes and Sending Emails

Imagine you have two tasks:

  • Task 1: Wash clothes using a washing machine

  • Task 2: Send an email to your client

Now here’s the question:
Will you stand in front of the washing machine doing nothing while the clothes wash, or will you start the machine and send the email while it runs in the background?

Of course, the second option makes more sense!
That’s the asynchronous approach: You start a time-consuming task (washing clothes), let it run in the background, and continue with other tasks in the meantime. When the background task finishes, you come back to it.


🔄 Enter Asynchronous JavaScript

Asynchronous JavaScript works exactly like that. For time-consuming operations (like fetching data or loading a file), JavaScript delegates the task to browser-provided APIs (like Web APIs), and continues with the rest of the code.

Once the task is complete and the JavaScript engine is free, the result is pushed back into the call stack and executed.

We’ll explore how exactly this works behind the scenes in JavaScript in another blog.
But for now , this brings us to the concept of callback functions.


📞 What Are Callback Functions?

Let’s go back to the analogy:

You call your friend, but they’re busy doing homework. So you say,
“Okay, call me back when you're done.”

This "call me back" is essentially a callback function — it defines what should happen after an asynchronous task is finished.

In JavaScript, we often pass functions as arguments to other functions. That passed function will be called later — as a callback — when the original task is complete.


🧠 Example: Loading a Script with Callbacks

Let’s look at a simple JavaScript example that loads a script asynchronously:

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(null, script); 
   // whenever the loading is done and there is no error , call the callback without error
  script.onerror = () => callback(new Error(`Script load error for ${src}`));
  // whenever the loading is done and there is error , call the callback with error

  document.head.append(script);
}

Now, let’s call this function and pass a callback:

loadScript("first.js", () => {
  console.log("Script has been loaded.");
  console.log("Do whatever you want to do after loading the script.");
});

console.log("Continue with other tasks...");

🧠 What’s Happening Here?

  • The script is added to the page, and JavaScript doesn’t wait for it to load.

  • While the script is loading in the background (via Web APIs), the program moves on and prints "Continue with other tasks...".

  • Once the script finishes loading, the callback is called, printing "Script has been loaded." and other follow-up actions.

This is asynchronous behavior in action!

🔥 Callback Hell: The Pyramid of Doom

Let’s say you want to load multiple scripts one after another — but only start the next one after the previous one has loaded successfully. You might write something like this:

loadScript('/my/script1.js', function(script) {
  loadScript('/my/script2.js', function(script) {
    loadScript('/my/script3.js', function(script) {
      // ...continue after all scripts are loaded
    });
  });
});

Looks okay for three scripts, right?

But what if you had to load 10 scripts, or even 100?

You’d end up with deeply nested callbacks, each sitting inside another — forming a triangle of doom. This is what developers call Callback Hell.


Now that you understand how callbacks work, it’s time to address one of the major issues with this approach — callback hell (also known as the pyramid of doom). This happens when callbacks are nested inside callbacks inside callbacks, leading to unreadable and hard-to-maintain code.

🤯 Why Is Callback Hell a Problem?

  • 🧩 Hard to Read: The more deeply nested your code gets, the harder it becomes to follow the logic.

  • 🧼 Hard to Maintain: Updating or debugging one part can mean untangling the whole structure.

  • 📄 Scattered Code: Even if you pull out callbacks into separate named functions, your code starts to look like torn sheets — scattered and hard to track.

Let’s see what that looks like when you try to separate out callback functions:

loadScript('1.js', step1);

function step1(error, script) {
  if (error) {
    handleError(error);
  } else {
    loadScript('2.js', step2);
  }
}

function step2(error, script) {
  if (error) {
    handleError(error);
  } else {
    loadScript('3.js', step3);
  }
}

function step3(error, script) {
  if (error) {
    handleError(error);
  } else {
    // All scripts loaded successfully
  }
}

Yes, the nesting is flattened, and it looks slightly cleaner. But here’s the catch:

  • 🧱 These functions (step1, step2, step3) are only used once.
    They’re not reusable in any meaningful way. So, splitting them out doesn’t add much value.

  • 📄 It makes the code feel like torn sheets of paper.
    Logic is now scattered across the file, breaking the flow of reading.
    Instead of one readable flow, you’re jumping from one function to another — often for no real reason.

  • 🔍 Still not elegant: You’ve traded deep nesting for fragmented logic, and in most cases, it still doesn't solve the bigger issue — managing complex async workflows cleanly.

So while standalone callbacks are a small improvement, they’re not a real solution.


🚀 The Real Solution: Promises

The best way to escape callback hell is with Promises — a more powerful and elegant way to handle asynchronous operations in JavaScript.
We’ll explore that in the next blog — and learn how Promises and async/await make asynchronous JavaScript cleaner and easier to manage.


✅ Summary

  • Synchronous JavaScript: Each operation waits for the previous one to finish. Great for simple, fast operations.

  • Asynchronous JavaScript: Time-consuming tasks (like I/O operations) are run in the background. Once complete, a callback function is called to handle the result.

  • Callback functions are the core of handling asynchronous behavior in JavaScript.


1
Subscribe to my newsletter

Read articles from Himanshu Rana directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Himanshu Rana
Himanshu Rana