JavaScript / ES6+ Learning – Lesson 7 – Asynchronous JavaScript, Event Loop, Promises and async/await

This is a major milestone for your JavaScript and Node.js understanding.

Coming from Ruby/Rails, the confusing part is usually this:

fetch("/users").then(...)

or:

setTimeout(() => {
...
}, 1000);

How can JavaScript start something, move on to other code, and then come back later?

The answer involves:

synchronous execution
call stack
Web APIs / Node.js APIs
callback queue
microtask queue
event loop
Promise
async/await

We will build these one at a time.


1. First: synchronous JavaScript

Start with completely normal execution:

console.log("A");
console.log("B");
console.log("C");

Output:

A
B
C

JavaScript executes the code sequentially.

Think:

A
↓
B
↓
C

Nothing surprising.


2. The call stack

JavaScript keeps track of function execution using something called the call stack.

Example:

function one() {
two();
}
function two() {
three();
}
function three() {
console.log("Hello");
}
one();

Execution roughly looks like:

one()
↓
two()
↓
three()
↓
console.log()

The stack grows:

three
two
one

Then functions finish and are removed.

This is a stack – last in, first out.


3. JavaScript itself is single-threaded

For normal JavaScript execution, there is one main execution thread.

That means JavaScript doesn’t execute:

A
B
C

simultaneously on the main JavaScript thread.

Instead:

A
↓
B
↓
C

This raises an obvious question:

Then how can JavaScript handle HTTP requests, timers, file operations, database calls, etc. without blocking everything?

This is where the runtime comes in.


4. JavaScript is not the whole runtime

This distinction is very important.

When you run JavaScript in a browser, you have:

Browser
├── JavaScript engine
├── Web APIs
└── event loop

With Node.js:

Node.js
├── V8 JavaScript engine
├── Node APIs
├── libuv
└── event loop

So when we say:

“JavaScript is single-threaded”

we are talking primarily about the JavaScript execution thread.

The surrounding runtime provides asynchronous capabilities.


5. setTimeout

Consider:

console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
console.log("C");

Output:

A
C
B

The important question is:

Why doesn’t "B" appear immediately?

Because setTimeout schedules the callback to run later.


6. What’s actually happening?

Think of:

setTimeout(() => {
console.log("B");
}, 1000);

as:

JavaScript
|
| "Please schedule this callback"
↓
Runtime timer system
|
| wait ~1 second
↓
callback becomes eligible
|
↓
event loop
|
↓
call stack
|
↓
callback executes

Meanwhile JavaScript continues:

console.log("C");

That’s why:

A
C
B

7. Very important: 1000 does NOT mean “execute exactly after 1 second”

This:

setTimeout(callback, 1000);

means approximately:

Don’t execute the callback before the timer delay has elapsed.

It doesn’t guarantee execution exactly at 1000 ms.

Why?

Because JavaScript might still be busy executing other code.

For example:

setTimeout(() => {
console.log("Timer");
}, 0);
for (let i = 0; i < 1_000_000_000; i++) {
// expensive work
}
console.log("Done");

The timer doesn’t interrupt the running JavaScript.

You’ll still see:

Done
Timer

The callback has to wait until the current JavaScript work finishes.


8. The event loop

Now we can define the event loop at a useful level:

The event loop coordinates when callbacks waiting for execution can be placed onto the JavaScript call stack.

Simplified:

              ┌─────────────────┐
              │   Call Stack    │
              └────────┬────────┘
                       │
                       │ finishes
                       ↓
                ┌─────────────┐
                │ Event Loop  │
                └──────┬──────┘
                       ↑
                       │
                ┌──────┴──────┐
                │   Queues    │
                └─────────────┘

The exact runtime implementation is more nuanced, but this model is excellent for int.s and day-to-day debugging.


9. Callback queue

Consider:

setTimeout(() => {
console.log("B");
}, 0);
console.log("A");

What happens?

The timer callback doesn’t go directly onto the call stack.

It becomes eligible and waits in a queue.

Conceptually:

Call Stack
|
| execute setTimeout
↓
Timer system
|
↓
Callback queue

Then the event loop eventually moves the callback to the call stack when the stack is empty.


10. Why setTimeout(..., 0) is still asynchronous

This:

setTimeout(() => {
console.log("B");
}, 0);
console.log("A");

prints:

A
B

not:

B
A

Even with 0, you told the runtime:

Schedule this callback for later.

So:

setTimeout(..., 0)

does not mean:

execute immediately.

It means:

schedule the callback asynchronously.


11. Now comes Promises

Callbacks work, but deeply nested callbacks can become difficult:

doSomething(a => {
doSomethingElse(a, b => {
doAnotherThing(b, c => {
doFinalThing(c => {
// ...
});
});
});
});

This style is sometimes called callback hell.

Promises provide a cleaner abstraction for asynchronous results.


12. What is a Promise?

A Promise represents:

A value that may be available now, later, or never because the operation may fail.

A Promise has three important states:

pending
fulfilled
rejected

Visualize:

          ┌──────────────┐
          │   pending    │
          └──────┬───────┘
                 │
          ┌──────┴───────┐
          ↓              ↓
      fulfilled       rejected

Once settled, it doesn’t go back to pending.


13. Creating a Promise

For learning purposes:

const promise = new Promise((resolve, reject) => {
const success = true;
if (success) {
resolve("Done");
} else {
reject("Something failed");
}
});

Here:

resolve → success
reject → failure

14. Consuming a Promise with .then()

promise.then(result => {
console.log(result);
});

If the Promise resolves with:

resolve("Done");

then:

Done

is passed to the callback.

This is another callback.

So:

promise.then(result => {
console.log(result);
});

means:

When the Promise successfully completes, call this function with the resolved value.


15. Handling errors with .catch()

promise
.then(result => {
console.log(result);
})
.catch(error => {
console.error(error);
});

The pattern is:

then → success
catch → failure
finally → always

Example:

fetch("/users")
.then(response => response.json())
.then(users => {
console.log(users);
})
.catch(error => {
console.error(error);
});

This is extremely common in browser JavaScript.


16. Promise chaining

This code:

fetch("/users")
.then(response => response.json())
.then(users => {
console.log(users);
});

contains a chain.

The important idea is:

Promise 1
↓
.then()
↓
returns Promise 2
↓
.then()

This works because .then() itself returns a Promise.


17. Why does response.json() return a Promise?

This surprises beginners.

You might think:

const response = await fetch("/users");
const users = response.json();

would make users the parsed object.

But:

response.json()

itself performs asynchronous parsing and returns a Promise.

So:

response.json()

is conceptually:

Promise<User[]>

That’s why you can write:

fetch("/users")
.then(response => response.json())
.then(users => {
console.log(users);
});

The second .then() receives the eventual parsed result.


18. async/await

Now we get to modern JavaScript’s preferred syntax for many asynchronous workflows.

Instead of:

fetch("/users")
.then(response => response.json())
.then(users => {
console.log(users);
})
.catch(error => {
console.error(error);
});

we can write:

async function loadUsers() {
try {
const response = await fetch("/users");
const users = await response.json();
console.log(users);
} catch (error) {
console.error(error);
}
}

This often looks much more like synchronous code.


19. What does async mean?

When you write:

async function loadUsers() {
}

the function always returns a Promise.

For example:

async function getNumber() {
return 10;
}

You might think:

const result = getNumber();

gives:

10

But it actually gives a Promise that fulfills to 10.

Conceptually:

getNumber()
↓
Promise
↓
10

Therefore:

const result = await getNumber();

gives:

10

inside an async context.


20. What does await mean?

Suppose:

const result = await getNumber();

A common beginner explanation is:

“await blocks JavaScript until the Promise finishes.”

That’s misleading.

A better mental model:

await pauses the continuation of the current async function until the Promise settles, while the JavaScript runtime can continue doing other work.

For example:

async function load() {
console.log("A");
const result = await fetch("/users");
console.log("B");
}
console.log("Start");
load();
console.log("End");

Conceptually:

Start
A
End
B

The load() function reaches await, then its continuation is suspended.

JavaScript can continue executing:

console.log("End");

When the Promise settles, execution resumes from:

console.log("B");

21. This is the crucial difference from synchronous waiting

Imagine Ruby code like:

response = make_http_request
puts response

The current Ruby execution waits for that call to return.

With JavaScript:

const response = await fetch("/users");

the async function yields control while the asynchronous operation is in progress.

That’s one of the key ideas behind Node.js scalability.


22. Microtask queue

Now we need one more piece.

Promises use a queue often described as the microtask queue.

Consider:

console.log("A");
Promise.resolve().then(() => {
console.log("B");
});
console.log("C");

Output:

A
C
B

The Promise callback doesn’t execute immediately.

It is scheduled as a microtask.


23. Microtasks vs timer callbacks

Now:

console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");

The result is:

A
D
C
B

Why?

A simplified model is:

1. Run current synchronous code
2. Process microtasks
3. Then process eligible timer/task callbacks

So:

A
D
↓
Promise microtask
↓
C
↓
timer callback
↓
B

This is a very common JavaScript int. question.


24. The async execution model

Here’s the mental model I want you to build:

                JavaScript
                    │
                    ↓
               Call Stack
                    │
        ┌───────────┴───────────┐
        │                       │
        ↓                       ↓
 synchronous             async operation
                             │
                     Browser / Node APIs
                             │
                    ┌────────┴─────────┐
                    ↓                  ↓
              Microtask Queue      Task Queue
                    │                  │
                    └────────┬─────────┘
                             ↓
                         Event Loop
                             ↓
                        Call Stack

This is simplified, but it’s a strong working model.


25. Promise.resolve()

You can easily create an already-resolved Promise:

Promise.resolve("Hello")
.then(value => {
console.log(value);
});

The callback still runs asynchronously as a microtask.

That’s why:

console.log("A");
Promise.resolve().then(() => {
console.log("B");
});
console.log("C");

gives:

A
C
B

26. Multiple awaits

Consider:

async function load() {
const a = await getA();
const b = await getB();
return [a, b];
}

Conceptually:

start
↓
getA()
↓
wait for Promise
↓
resume
↓
getB()
↓
wait for Promise
↓
resume
↓
return

This is sequential.

If getA() and getB() are independent, you can often improve latency with:

const [a, b] = await Promise.all([
getA(),
getB()
]);

Now both operations can be started concurrently.


27. Promise.all

Example:

const [users, products] = await Promise.all([
fetchUsers(),
fetchProducts()
]);

Instead of:

const users = await fetchUsers();
const products = await fetchProducts();

The second version waits for fetchUsers() before starting fetchProducts() if those function calls themselves initiate the asynchronous operations at invocation.

With Promise.all:

fetchUsers() ────────┐
├──→ Promise.all
fetchProducts() ────────┘

They can progress concurrently.

This is one of the most useful async performance patterns.


28. Promise.all failure behavior

Suppose:

const results = await Promise.all([
fetchUsers(),
fetchProducts(),
fetchOrders()
]);

If one Promise rejects, Promise.all rejects.

That means it is appropriate when:

I need all of these operations to succeed.

When that’s not what you want, Promise.allSettled() may be more appropriate.

We’ll cover the Promise combinators separately.


29. Common Node.js example

Imagine an HTTP API needs:

user
orders
recommendations

Sequential:

const user = await getUser(id);
const orders = await getOrders(id);
const recommendations = await getRecommendations(id);

Potentially:

user
↓
orders
↓
recommendations

If they’re independent:

const [user, orders, recommendations] = await Promise.all([
getUser(id),
getOrders(id),
getRecommendations(id)
]);

Now:

getUser() ────────┐
getOrders() ────────┼──→ Promise.all
getRecommendations() ──────┘

This can reduce overall latency substantially when the operations run concurrently.


30. A critical misunderstanding about Node.js

You may hear:

“Node.js is single-threaded.”

Don’t interpret that as:

“Node.js can only do one thing at a time.”

A better explanation:

JavaScript execution runs primarily on a single main thread, while Node.js delegates asynchronous I/O to its runtime and underlying system facilities, allowing other JavaScript work to continue.

This is one of the most common Node.js int. topics.


31. CPU-bound work is different

Suppose you do:

while (true) {
// massive CPU computation
}

The event loop is stuck.

No incoming request can be processed by that same JavaScript thread while it is occupied with that synchronous work.

So Node.js is excellent for many I/O-heavy workloads, but CPU-heavy work needs different strategies such as:

worker threads
child processes
separate services
job queues

The important architectural distinction is:

I/O-bound
→ async works very well
CPU-bound
→ can block the event loop

32. React connection

Now look at a common React example:

useEffect(() => {
async function loadUsers() {
const response = await fetch("/users");
const users = await response.json();
setUsers(users);
}
loadUsers();
}, []);

You can now break it down:

useEffect
↓
receives a callback
↓
callback creates async function
↓
fetch returns Promise
↓
await pauses async function continuation
↓
Promise resolves
↓
function resumes
↓
setUsers(...)
↓
React state update

None of that requires React magic.

It’s JavaScript asynchronous programming + React state.


33. Why don’t we make the useEffect callback itself async?

You’ll often see:

useEffect(async () => {
...
}, []);

This isn’t the recommended pattern.

The effect callback is expected to either perform its effect and optionally return a cleanup function, rather than return a Promise.

Instead:

useEffect(() => {
async function loadUsers() {
...
}
loadUsers();
}, []);

This will make more sense once we revisit useEffect later.


34. Common int. question

What is the output?

console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => {
console.log("3");
});
console.log("4");

Answer:

1
4
3
2

Mental trace:

synchronous:
1
4
microtasks:
3
timer/task:
2

This is an excellent event-loop test.


35. Another one

What is the output?

async function test() {
console.log("A");
await Promise.resolve();
console.log("B");
}
console.log("C");
test();
console.log("D");

Think carefully.

Output:

C
A
D
B

Why?

test() starts synchronously:

C
↓
test()
↓
A

Then:

await Promise.resolve();

suspends the remainder of the async function.

JavaScript continues:

D

Then the continuation runs as a microtask:

B

🧠 Ruby developer mental model

When you see:

const result = await something();

don’t think:

“JavaScript stops.”

Think:

“This async function cannot continue until the Promise settles, so its continuation is suspended and the runtime can continue processing other work.”

That’s a much better Node.js mental model.


🎯 Int. questions

Try answering these before looking at the explanation.

Q1

What does “JavaScript is single-threaded” actually mean?

Q2

What is the event loop?

Q3

Does this execute immediately?

setTimeout(callback, 0);

Q4

What’s the difference between:

call stack
microtask queue
task/callback queue

Q5

What are the three Promise states?

Q6

What does async do to a function?

Q7

What does await do?

Q8

Why is this often better for independent operations?

await Promise.all([
getUsers(),
getProducts()
]);

Q9

Why can a large synchronous CPU operation hurt a Node.js server?


🧪 Live coding practice

Problem 1 – Predict the output

console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");

Problem 2 – Promise vs timer

Predict:

console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");

Problem 3 – Async function

Predict:

async function test() {
console.log("A");
await Promise.resolve();
console.log("B");
}
console.log("C");
test();
console.log("D");

Problem 4 – Create a Promise

Write:

function getUser() {
// return a Promise
}

It should resolve with:

{
id: 1,
name: "John"
}

Then consume it with:

getUser().then(user => {
console.log(user);
});

Problem 5 – Convert .then() to async/await

Convert:

fetch("/users")
.then(response => response.json())
.then(users => {
console.log(users);
})
.catch(error => {
console.error(error);
});

into an async/await version using try/catch.


Problem 6 – Sequential vs concurrent

Given:

const users = await getUsers();
const products = await getProducts();

Rewrite it using Promise.all().

Then explain when it is safe to do so.


One diagram to remember

Keep this mental picture:

              JavaScript
                  │
                  ↓
             CALL STACK
                  │
          ┌───────┴────────┐
          │                │
          ↓                ↓
     synchronous       async operation
                           │
                           ↓
                    Runtime APIs
                           │
                  ┌────────┴────────┐
                  ↓                 ↓
            Microtask Queue     Task Queue
                  │                 │
                  └────────┬────────┘
                           ↓
                       Event Loop
                           ↓
                      CALL STACK

And the modern JavaScript progression is now:

Function
↓
Callback
↓
Asynchronous callback
↓
Promise
↓
.then / .catch
↓
async / await
↓
Promise.all

We now have the foundation to understand how Node.js actually handles concurrent I/O without creating one JavaScript thread per request.

Next lesson: ES6 modules, imports/exports, default vs named exports, CommonJS vs ES Modules, and how Vite/React/Node organize JavaScript files.

After that, we’ll move into modern JavaScript operators and syntax – optional chaining, nullish coalescing, template literals, default parameters, logical operators and common patterns we’ll see constantly in React and Node.js.

Unknown's avatar

Author: Abhilash

Hi, I’m Abhilash! A seasoned web developer with 15 years of experience specializing in Ruby and Ruby on Rails. Since 2010, I’ve built scalable, robust web applications and worked with frameworks like Angular, Sinatra, Laravel, Node.js, Vue and React. Passionate about clean, maintainable code and continuous learning, I share insights, tutorials, and experiences here. Let’s explore the ever-evolving world of web development together!

Leave a Reply