Skip to main content

Synchronous vs Asynchronous JavaScript: How Code Actually Executes

Updated 13 min readMarkdown
Synchronous vs Asynchronous JavaScript: How Code Actually Executes

Before you can truly understand Promises, async/await, or event listeners, you need to understand one more fundamental thing: how JavaScript decides when to run your code.

Most of the time, JavaScript runs your code the way you would expect — one line, then the next, then the next. That is synchronous execution. But some operations take time — fetching data from a server, reading a file, waiting for a user to click. If JavaScript had to wait for each of those before moving on, every app would freeze constantly.

Asynchronous code is JavaScript's answer to that problem. Understanding the difference between the two — and why the difference exists — is what makes the rest of async JavaScript make sense.


What Synchronous Code Means

Synchronous means one thing at a time, in order. Each line of code runs completely before the next one starts. If a line takes a long time, everything else waits.

console.log("Step 1: Start");
console.log("Step 2: Middle");
console.log("Step 3: End");

Output:

Step 1: Start
Step 2: Middle
Step 3: End

No surprises. Line 1 finishes, then line 2 runs, then line 3. The order in the output matches the order in the code exactly.

This is how most programming works in everyday contexts — a recipe, a to-do list, a set of instructions. Do step one completely. Then step two. Then step three.

A synchronous function call

function greet(name) {
  const message = `Hello, ${name}!`;
  return message;
}

const result = greet("Priya");
console.log(result);
console.log("Done");

Output:

Hello, Priya!
Done

When greet("Priya") is called, JavaScript enters the function, runs every line inside it, and returns the result. Only then does the next line (console.log(result)) run. The function call blocks everything else until it finishes.

For fast operations like this one, blocking is not a problem. The function runs in microseconds. No one notices the wait.

The problem comes when an operation takes real time.


The Problem with Blocking Code

Imagine you need to fetch a user's profile from a server. The request might take 500ms, or 2 seconds, or more — depending on the network, the server load, the response size.

In a purely synchronous world, that looks like this:

console.log("Loading profile...");
const profile = fetchProfileSync("https://api.example.com/user/1");
// JavaScript freezes here for 2 seconds
console.log("Profile loaded:", profile.name);
console.log("Page is ready");

During those 2 seconds — while fetchProfileSync is running — nothing else happens. The browser cannot respond to clicks. Animations freeze. The scroll bar stops. Input fields do not register keystrokes. From the user's perspective, the page is completely unresponsive.

This is called blocking code: code that stops everything else from running until it finishes.

A restaurant analogy

Think of a restaurant with one waiter (the JavaScript thread). In a synchronous model, the waiter takes an order, walks to the kitchen, stands at the counter and waits for the food to be prepared, then brings it back to the table. While waiting at the kitchen, every other table in the restaurant is completely ignored. No drinks refilled. No menus handed out. No bills processed.

That waiter would get terrible reviews.

In an asynchronous model, the waiter takes an order, hands it to the kitchen, then immediately goes to attend to other tables. When the kitchen finishes the food, they notify the waiter, who then delivers it. The waiter is never idle, and no table is neglected.

JavaScript works the same way — and it learned this lesson early.


What Asynchronous Code Means

Asynchronous means that some operations are started and then deferred — JavaScript kicks them off, moves on to the next line of code, and handles the result when it arrives later.

console.log("1: Before fetch");

setTimeout(function() {
  console.log("3: Inside timeout");
}, 2000);

console.log("2: After fetch");

Output:

1: Before fetch
2: After fetch
3: Inside timeout   (appears 2 seconds later)

setTimeout does not pause JavaScript for 2 seconds. It registers a task to run after 2 seconds and immediately returns. JavaScript continues to the next line. Two seconds later, when the timer fires, the callback runs.

The order of the output does not match the order of the code — and that is the key difference from synchronous execution. Line 3 in the source code runs after line 4 in the output's time.

What "non-blocking" means

Non-blocking code starts an operation and does not wait for it to finish before moving on. The operation runs in the background (handled by the browser or Node.js runtime), and your code is notified when it is done.

// Non-blocking — does not freeze the page
fetch("https://api.example.com/user/1")
  .then(response => response.json())
  .then(user => console.log("User:", user.name));

console.log("This runs immediately, before the user data arrives");

The fetch call starts the network request and returns a Promise immediately. JavaScript does not wait for the response. It runs the next console.log right away. When the response eventually arrives, the .then() callbacks run.


Why JavaScript Needs Asynchronous Behavior

JavaScript is single-threaded. There is one call stack, one thread of execution. Unlike languages that can spawn multiple threads to handle concurrent work, JavaScript does everything on a single thread.

This is not a flaw — it is a deliberate design choice that makes JavaScript simpler to reason about (no race conditions, no deadlocks). But it means that if any operation takes a long time and blocks the thread, everything stops.

The operations that take meaningful time are:

  • Network requests — fetching data from APIs, loading resources

  • File system operations — reading or writing files (mainly in Node.js)

  • Timers — setTimeout, setInterval

  • User events — waiting for clicks, keypresses, form submissions

  • Database queries — reading from or writing to a database

None of these have predictable or controllable duration. A network request might take 100ms or 5 seconds. A user might click a button immediately or never. JavaScript cannot afford to block the thread waiting for any of them.

The solution is to handle them asynchronously — start the operation, register a function to run when it completes, and move on.


Examples: API Calls and Timers

Timers

setTimeout and setInterval are the simplest examples of asynchronous behavior:

console.log("Start");

setTimeout(() => {
  console.log("This runs after 1 second");
}, 1000);

setTimeout(() => {
  console.log("This runs after 0 seconds");
}, 0);

console.log("End");

Output:

Start
End
This runs after 0 seconds   (after all synchronous code finishes)
This runs after 1 second    (1 second after script started)

Even setTimeout(fn, 0) does not run the callback immediately. It waits for all synchronous code to finish first. The 0 means "as soon as possible after the current synchronous work is done" — not "right now."

This reveals something important: asynchronous callbacks never interrupt synchronous code. They always wait in a queue until the synchronous code finishes.

API calls

console.log("Fetching user...");

fetch("https://api.example.com/users/1")
  .then(response => response.json())
  .then(user => {
    console.log("User received:", user.name);
  });

console.log("Fetch started, moving on");

Output:

Fetching user...
Fetch started, moving on
User received: Priya      (arrives whenever the server responds)

The fetch call starts the HTTP request and returns immediately. JavaScript does not stand at the network waiting for bytes to arrive. It moves to the next statement. When the response eventually arrives, the .then() callbacks run.

Multiple async operations running concurrently

Because asynchronous operations do not block the thread, multiple can run at the same time:

console.log("Starting requests");

fetch("/api/users").then(r => r.json()).then(data => {
  console.log("Users loaded:", data.length);
});

fetch("/api/posts").then(r => r.json()).then(data => {
  console.log("Posts loaded:", data.length);
});

fetch("/api/comments").then(r => r.json()).then(data => {
  console.log("Comments loaded:", data.length);
});

console.log("All requests started");

All three network requests are in flight simultaneously. Whichever server responds first, that callback runs first. The total time is roughly the duration of the slowest request — not the sum of all three.

In a synchronous world, this would take (time for users) + (time for posts) + (time for comments). In an asynchronous world, it takes max(time for users, time for posts, time for comments).


How JavaScript Handles This: The Event Loop

Understanding why async behavior works the way it does requires knowing about the event loop — the mechanism that makes it all possible.

JavaScript has three key components:

The call stack is where your synchronous code runs. Functions are pushed onto the stack when called and popped off when they return. Only one thing runs at a time.

The Web APIs / Node APIs are where async operations actually happen — the browser or Node.js runtime handles timers, network requests, and file reads outside of the JavaScript thread. When the operation completes, it sends the callback to the next component.

The callback queue (task queue) is where completed async callbacks wait. When the call stack is empty — when all synchronous code has finished — the event loop picks the next callback from the queue and pushes it onto the stack to run.

Call Stack          Web APIs             Callback Queue
──────────          ────────             ──────────────
main()         →   setTimeout (2000ms)
console.log()       fetch (network)
...                 setInterval
(finishes)                          →   [callback waiting]
                                         ↓ (event loop picks it up)
                                    → Call Stack runs callback

This is why setTimeout(fn, 0) still waits for synchronous code to finish. The callback goes to the queue, and the queue is only processed when the call stack is empty.

Microtasks: where Promises queue

Promise callbacks (.then(), .catch(), async/await) go into a microtask queue, which has higher priority than the regular callback queue. Microtasks are processed after every task, before any new task from the queue runs.

console.log("1: sync start");

setTimeout(() => console.log("4: setTimeout callback"), 0);

Promise.resolve().then(() => console.log("3: promise callback"));

console.log("2: sync end");

Output:

1: sync start
2: sync end
3: promise callback      (microtask — runs before setTimeout)
4: setTimeout callback   (regular task — runs after microtasks)

Promise callbacks always run before timer callbacks, even when the timer has a zero delay. This is why the async/await examples earlier in this series behave consistently — Promise resolution is processed in a predictable, high-priority queue.


Synchronous vs Asynchronous: A Direct Comparison

Synchronous Asynchronous
Execution Line by line, in order Some tasks deferred until completion
Blocking Yes — next line waits No — continues immediately
Output order Matches code order May differ from code order
Use case Fast calculations, data transforms Network, timers, file I/O, events
Risk Freezes UI for slow operations Requires careful error handling

Real Consequences of Blocking Code

Blocking in a browser does not just mean "slow." It means the page literally becomes unresponsive. The browser cannot repaint, cannot respond to input, cannot run any other JavaScript. Users see a frozen page.

Here is a demonstration of what blocking looks like:

// Simulates a slow synchronous operation
function blockFor(ms) {
  const start = Date.now();
  while (Date.now() - start < ms) {
    // burning CPU time
  }
}

document.getElementById("btn").addEventListener("click", () => {
  console.log("Button clicked");
  blockFor(3000); // blocks for 3 seconds
  console.log("Done blocking");
});

When the button is clicked, blockFor(3000) runs. For three full seconds, the browser cannot do anything — no scrolling, no other clicks, no animations. The console.log("Done blocking") only appears after those three seconds.

This is why CPU-intensive work (image processing, data crunching, encryption) should be moved to Web Workers — separate threads that can run heavy computation without blocking the main thread.

Compare that to the non-blocking version using a timer:

document.getElementById("btn").addEventListener("click", () => {
  console.log("Button clicked — starting task");

  setTimeout(() => {
    console.log("Task complete"); // runs after 3 seconds, without blocking
  }, 3000);

  console.log("Button handler finished — page remains responsive");
});

The page remains fully interactive during those 3 seconds because the timer callback is deferred. The work happens outside the main thread.


Blocking vs Non-Blocking in Node.js

In Node.js — where JavaScript runs on servers — blocking is even more damaging. A Node.js server handles all client requests on a single thread. If one request triggers a blocking operation, every other client waiting for a response is stalled.

// Blocking file read — freezes the server for every client
const fs = require("fs");

app.get("/file", (req, res) => {
  const data = fs.readFileSync("large-file.txt"); // blocks
  res.send(data);
});

// Non-blocking file read — server remains responsive
app.get("/file", (req, res) => {
  fs.readFile("large-file.txt", (err, data) => { // non-blocking
    if (err) return res.status(500).send("Error");
    res.send(data);
  });
});

In the synchronous version, the server stops handling all other requests while the file is being read. In the asynchronous version, it starts reading the file, moves on to handle other requests, and sends the response when the file is ready.

This is why Node.js is built almost entirely on asynchronous APIs. It is designed around the non-blocking model.


When to Use Each

Use synchronous code for:

  • Calculations and data transformations that happen in memory

  • Reading values from variables

  • Any operation that completes in microseconds and has no I/O

  • Code that depends strictly on the result of the previous line

Use asynchronous code for:

  • Any network request

  • File system reads and writes

  • Timers and scheduled tasks

  • Database queries

  • Any operation whose duration you cannot control

The practical rule is straightforward: if the operation involves waiting for something outside your JavaScript code, it should be asynchronous.


Wrapping Up

Synchronous code is predictable and simple — one line at a time, in order. But that simplicity becomes a liability the moment any line takes noticeable time. A single slow synchronous operation freezes everything.

Asynchronous code solves this by decoupling the start of an operation from the handling of its result. JavaScript starts the operation, moves on, and handles the result when it arrives — keeping the thread free to do other work in the meantime.

The event loop is the mechanism that makes this coordination possible. Synchronous code runs first, completely. Microtasks (Promise callbacks) run next. Macrotasks (timers, I/O callbacks) run after. That ordering is what makes the execution of async code predictable once you understand the model.

Callbacks, Promises, and async/await are all different syntaxes for expressing the same idea: "start this, and when it finishes, do that." Understanding synchronous vs asynchronous execution is what makes all three of those patterns make sense.

No comments yet. Be the first to comment.

More from this blog