When I first started working with JavaScript, I used async functions like 'fetch()' or 'setTimeout()' every day — but I never really understood how they worked behind the scenes.
It was just "magic": you call something, and sometime later, it gives you back a result.
Recently, I decided to dive deeper into how JavaScript handles asynchronous operations — and it completely changed the way I
see JavaScript.
In this post, I’ll explain (in simple terms) how the call stack, Web APIs, callback queues, and the event loop work together
to make async code possible.
JavaScript is Single Threaded
JavaScript is a single-threaded language.This means, JS uses a single call stack. So, only one piece of code can run at a time.
console.log(1);
console.log(2);
console.log(3);
For this simple program, single line is executed at a time. Each 'console.log()' is added to call stack, executed and poped off once completed. So code, executes synchronously, line by line, top to bottom.
That’s fine for most things — until we hit something that takes time.
The Problem with blocking code
Imagine calling a function that takes 5 second to execute. Since JS runs synchronously, it'll just.. wait. Nothing else runs until that function finishes. This blocks the stack — and with it, your entire app.
This is not ideal. Imagine a network request blocks the stack and makes our entire application unresponsive.
Enter Asynchronous Programming
To avoid blocking, JavaScript (along with the environment it runs in — like browsers or Node.js) uses a set of tools: Web APIs, callback/task queues, and the event loop.
This trio helps JS handle things like network requests, timers, or file operations without freezing the app.
Web APIs:
Web APIs are a set of features provided by the environment (like the browser or Node.js) to handle operations that might otherwise block the main call stack.
For example, the fetch function — used to retrieve data from the internet — is a Web API provided by the browser.
When you call fetch, it runs outside of the main call stack, so it doesn't block the rest of your code from running.
-
Callback/task queue
When you use Web APIs, you often provide a callback function — a piece of code that should run after the operation finishes.
fetch("https://example-api.com/data", { }).then((res) => // Do something with data returned ); // Rest of code
In the above example, we make a fetch request. The .then method is used to register a callback function. When the response is available, the function you provided inside .then is queued — it waits in the callback queue.
The callback queue holds all such functions waiting to be executed.
But they don't immediately run — this is where the event loop comes in.
-
Event Loop
The event loop is a loop that constantly checks two things:
- Is the call stack empty?
- Are there any functions in the callback queue?If the call stack is empty and there’s a function waiting in the callback queue, the event loop pushes that function onto the call stack, allowing it to run.
This is how JavaScript handles asynchronous behavior without blocking the main thread.
How it all works (High level)
When you run a function like fetch(), the browser (not JavaScript itself) handles it using its Web APIs. Once the operation completes, it doesn’t just jump back into the call stack. Instead, a callback function (or a resolve function in case of a promise) is queued.
That callback sits in a task queue or microtask queue. After the main code is done (i.e., the call stack is empty), the event loop starts checking the queues.
If it finds any callbacks, it pushes them onto the call stack — where they get executed like any other function.
This makes async feel synchronous in some cases, without actually blocking the code.
This system confused me for a long time. I used async code every day, but I never asked what actually happens. Once I saw the call stack, Web APIs, queue, and event loop as a team, it all made sense.