How to Stop Blocking the Main Thread: Understanding Event Loop
For years, my backend world was defined by PHP. I understood its request lifecycle implicitly: a request arrives, a dedicated thread or process grabs it, executes the code linearly, and blocks whenever it hits a database or API call. It’s a comfortable world where handling concurrency means spinning up parallel workers. It’s “true” multithreading in the traditional sense.
Switching gears to Node.js was a paradigm shock. Here was a runtime boasting incredible high-concurrency performance while running effectively on a single thread. It seemed paradoxical. How can it execute heavy background tasks without interfering with incoming requests? How does it handle I/O without waiting? The answer wasn’t magic; it was a fascinating architectural shift centered around one crucial concept: the Event Loop.
What the Article Should Cover
- Multi-Threaded Model
- Single-Threaded Model
- Event Loop Components
- How does JavaScript know if a task will take time or not?
- How can we avoid the blocking risks ?
Before diving into the event loop, it’s important to understand two fundamental execution models: multi-threaded and single-threaded.
Multi-Threaded Model
For many backend developers, concurrency means parallelism.
Imagine a standard PHP setup (like Apache with mod_php or PHP-FPM). When a request hits your server, the server assigns a dedicated thread or process to handle that specific request from start to finish.
If that script needs to query a database, it sends the query and then… it waits. It blocks. That thread sits idle until the database responds.
The Supermarket Analogy: Multiple Checkouts Think of this like a supermarket with 10 open checkout lanes.
Each lane is a thread, Each customer is an HTTP request.
If a customer in Lane 3 needs a complicated price check, Lane 3 stops moving.
However, Lanes 1, 2, and 4–10 keep processing customers happily.
This model is great because tasks are isolated. A heavy calculation in one request won’t easily block another. But it comes at a cost: threads are heavy.
Single-Threaded Model
JavaScript acts differently. By design, JavaScript is single-threaded.
This means it has one call stack and one memory heap. It can only do exactly one thing at a time. If you run an infinite while(true) loop in JavaScript, your entire application freezes.
The Supermarket Analogy: The Single Super-Cashier Now, imagine that same supermarket, but it only has one checkout lane.
If this one cashier behaved like a PHP thread, the store would be a disaster. The first person needing a price check (a database query) would halt the entire store. Everyone behind them would wait.
The Solution: Asynchronous Non-Blocking I/O JavaScript solves this by being asynchronous. The single JavaScript thread is like a super-fast, hyper-efficient cashier who refuses to wait for anything.
When a customer needs a price check (an I/O operation like fetch or a database call), the cashier doesn’t wait. They delegate the task to a store manager (the system’s underlying APIs), push that customer to a side holding area, and immediately take the next customer in line.
When the manager returns with the price, the original customer is put back into the flow.
How does JavaScript manage this delegation and queuing without losing track of anything?
That’s where the architecture of the Event Loop comes in.
Event Loop Components
The “Event Loop” is often used as an umbrella term for the entire mechanism, but it’s actually just one part of a larger system. The magic happens in the interaction between four key components.
1. The Call Stack
This is familiar territory for any developer. It’s a LIFO (Last In, First Out) data structure that tracks where we are in the program.
- When a function executes, it’s pushed onto the stack.
- When a function returns, it’s popped off the stack.
- Crucial Point: As long as something is on the Call Stack, the JavaScript engine is busy. It cannot do anything else until the stack is empty.
2. Web APIs / Node APIs (The Background Workers)
This is the secret sauce. When people ask, “If JS is single-threaded, how are the timer and the network request running at the same time?”, the answer is APIs.
The JavaScript runtime (like the browser or Node.js) provides APIs that exist outside the main JavaScript engine. These are often written in C++.
When you call setTimeout or fetch, you aren't actually doing the waiting in JavaScript. You are handing that task off to these external APIs. The browser/Node spins up its own separate threads to handle these tasks in the background, leaving the main JS Call Stack free to keep executing code.
3. The Callback Queue (or Task Queue)
This is a FIFO (First In, First Out) holding area.
Remember the customer pushed aside for a price check? When the background API finishes its job (the timer runs out, or the data comes back from the server), it doesn’t immediately shove the result back onto the Call Stack. Doing so would interrupt whatever code is currently running.
Instead, the API places the callback function associated with that task into the Callback Queue. It sits there, waiting its turn.
4. The Event Loop (The Conductor)
Finally, the Event Loop itself. It has an incredibly simple job. It acts like an infinite loop that constantly checks two things:
- Is the Call Stack empty?
- Is there anything in the Callback Queue?
If the Call Stack is not empty, the Event Loop does nothing. It waits.
If the Call Stack is empty (meaning the main script has finished executing), the Event Loop takes the first item from the Callback Queue and pushes it onto the Call Stack, causing it to execute.
Example :
console.log('hello');
setTimeout(function() {
console.log('Timeout is over!');
}, 0);
console.log('hello');
Output:
# hello
# hello
# Timeout is over!How does JavaScript know if a task will take time or not?
JavaScript itself actually doesn’t know how long a task will take. It doesn’t look at a database query and think, that’s a big table, I should run this in the background.
Get Keroles Massoud’s stories in your inbox
Join Medium for free to get updates from this writer.
However, this depends on several factors, including:
1. It’s All About the Signature (Function Name)
JavaScript identifies “time-consuming” tasks based on the functions you use. In the JavaScript environment (whether it’s the Browser or Node.js), certain functions are pre-coded to be asynchronous.
When the JavaScript engine (like V8) sees functions like:
setTimeout()orsetInterval()fetch()orXMLHttpRequestfs.readFile()(in Node.js)pbkdf2(crypto operations)
It immediately knows: “I am not designed to handle this. This is a job for the environment APIs.”
2. The Hand-off
JavaScript is like a manager who refuses to do any heavy lifting.
- Step A: The engine sees a
fetch()call. - Step B: Instead of trying to connect to the internet itself, it hands the request details (URL, headers, callback function) to the Web API (in browsers) or Libuv (in Node.js).
- Step C: The engine then immediately moves to the next line of code. It doesn’t wait to see if the API is busy; it just trusts that the API will handle the “time” part.
3. The Role of Libuv (The Engine Room)
Since you are a PHP developer, you’ll appreciate the “under the hood” part. In Node.js, there is a library called Libuv (written in C).
When you call a time-consuming operation (like reading a 5GB file), Libuv is the one that actually looks at the task. It uses a Thread Pool to perform the heavy lifting. While Libuv’s threads are sweating over the file data, the main JavaScript thread is still free to handle other incoming requests.
4. What about “Heavy” Math? (The Trap)
This is where many developers get stuck. If you write a function that calculates the millionth prime number using a for loop, JavaScript does NOT know this will take time.
Because a for loop is pure JavaScript (not a Web API or a Node API), the engine tries to run it on the Call Stack.
- It won’t hand it off.
- The Event Loop will be blocked.
- The UI will freeze (in the browser) or the server will stop responding (in Node)
The Rule of Thumb: JavaScript only “knows” a task will take time if you use an Asynchronous API provided by the environment. If you write the “heavy” logic yourself in plain JS, you are responsible for the blocking.
How can we avoid the risks that could hinder the main thread?
Since JavaScript is single-threaded, blocking the main thread is the quickest way to kill your application’s performance. As a PHP developer, you’re used to a request failing or slowing down in isolation; in Node.js, if you block the main thread, you block every user currently connected to the server.
1. Identify the “Hidden” Blockers
Most developers know that a huge for loop is bad, but many block the main thread accidentally using built-in functions that are synchronous.
- JSON Processing:
JSON.parse()andJSON.stringify()are synchronous. If you are parsing a 50MB JSON file, the Event Loop stops until it's finished. - Regular Expressions: A poorly written Regex (ReDoS) can lead to “catastrophic backtracking,” locking the CPU for seconds.
- Synchronous File System Calls: In Node.js, never use functions ending in
Sync(likefs.readFileSync) in a request handler.
2. Offload to Worker Threads (The “PHP-like” Solution)
If you have heavy CPU-bound work (like image processing, video encoding, or complex math), you can use Worker Threads. This allows you to run JavaScript in parallel on a separate thread, much like how you might imagine a separate PHP process working.
- The Main Thread handles the Event Loop and I/O.
- The Worker Thread handles the heavy calculation.
- They communicate via “messages,” so the main thread is never blocked.
3. Partitioning (Breaking up the work)
If you have a long-running task that must stay on the main thread, don’t do it all at once. Break it into smaller chunks and use setImmediate() or setTimeout() to let the Event Loop "breathe" between chunks.
4. Use Asynchronous Alternatives
Always opt for the async version of a library.
- Use
bcrypt.hash(async) instead ofbcrypt.hashSync. - Use the
fs.promisesAPI for file operations. - If a library doesn’t offer an async version and it involves heavy CPU work, it’s likely a candidate for a Worker Thread.
Conclusion
Transitioning from the multi-threaded world of PHP to the single-threaded nature of Node.js felt like learning to breathe underwater. My initial skepticism, how could one thread possibly compete with many? was replaced by a deep respect for the elegance of the Event Loop.
The “magic” of Node.js isn’t that it’s faster at math or more powerful than PHP; it’s that it is incredibly efficient at waiting. By offloading time-consuming tasks to the system’s background workers and keeping the main thread clear, JavaScript turns the traditional blocking model on its head.
As backend developers, our responsibility changes when we move to Node.js. We are no longer just writing logic; we are guarding the loop. When we respect the single-threaded nature of the environment and use asynchronous patterns correctly, we can build applications that handle thousands of users with a level of resource efficiency that was previously unthinkable.
The Event Loop isn’t just a technical detail; it’s the heartbeat of modern web development. Understanding it doesn’t just make you a better Node.js developer, it changes the way you think about performance and concurrency forever.
Resources
The “Must Watch” Video
- What the heck is the event loop anyway? by Philip Roberts. This is the gold standard for visualizing how the stack, web APIs, and queue interact.
Official Documentation
- The Node.js Event Loop, Timers, and process.nextTick(): The definitive technical guide to the different phases (Timers, Poll, Check) of the loop.
Interactive Playground
- Loupe: An interactive tool where you can paste your own JS code and see it move through the Stack, Web APIs, and Task Queue in real-time.
—
Keroles Masoud
Backend Engineer
GitHub: https://github.com/boiar
LinkedIn: https://www.linkedin.com/in/keroles-masoud










