Node.js Concurrency Model Explained for Java Developers
A Java developer’s deep dive into Node.js concurrency, event loop, libuv, and when to use worker threads.

Last week, I ran into a production issue in a Node.js application while the service owner was on leave.
As a lifelong Java developer, I was nervous — I’d never touched Node.js before, and the fix was urgent.
Thanks to Windsurf AI Agent, I managed to push a fix quickly. But this experience made me realize:
if I ever have to work on Node.js again, I can’t just wing it — I need to understand it.
So, I started digging into how Node.js handles concurrency. Coming from Java, the first thing that shocked me was that Node.js is single-threaded.
My immediate questions were:
- How does Node.js handle high concurrency if it’s single-threaded?
- How can it manage 1000–5000 requests per second?
- Is it even built for such workloads?
Here’s what I learned.
Key Takeaways
- Node.js is single-threaded but uses the event loop and libuv to handle I/O efficiently.
- Network I/O is non-blocking — no extra threads are created.
- Blocking I/O (like file reads) uses a libuv thread pool (default 4 threads).
- CPU-heavy tasks still block the main thread — use Worker Threads or offload to other services.
- Ideal for I/O-heavy apps, not CPU-bound workloads.
How Node.js Handles Concurrency
Node.js runs a single-threaded event loop.
Here’s the basic flow:
- Incoming requests hit the main thread.
- CPU-bound work is executed immediately in that thread.
- I/O-bound work is delegated to libuv.
- libuv either:
- Registers non-blocking I/O with the OS (e.g., DB queries, HTTP requests).
- Assigns blocking I/O (e.g., file reads) to a thread pool.
- When results are ready, the event loop picks them up and executes callbacks.
Blocking vs Non-blocking I/O
| I/O Type | Example | How libuv handles it |
| Network I/O | HTTP, TCP, UDP, sockets | Uses non-blocking kernel APIs (epoll/kqueue/IOCP). No thread per request. |
| Blocking I/O | fs.readFile, DNS (sync) | Uses libuv thread pool (default 4 threads). Threads process the task, then return it. |
Example: HR Management System
Imagine an HR system built with Node.js.
When an employee clicks Download Salary Slip:
Case 1: DB fetch (Network I/O)
Thousands of Requests
↓
[Node.js Main Thread]
↓
Runs JS + event loop
↓
If CPU-bound → compute (basic validations)
If I/O-bound (e.g., DB)
↓
Register with OS via libuv (no thread)
↓
OS monitors socket readiness
↓
On response → notify libuv
↓
libuv queues callback
↓
Node.js event loop picks up
↓
Executes callback
↓
Sends response to client
Case 2: Disk read (Blocking I/O)
Node.js Event Loop
↓
Blocking task (e.g., fs)
↓
Delegated to libuv thread pool
↓
libuv uses a thread
↓
Thread completes task
↓
Notifies libuv queue
↓
Node.js executes callback
This consumes one of the thread pool slots.
Visual Diagram
┌────────────────────────────┐
│ Thousands of Clients │
└────────────┬───────────────┘
│
HTTP Requests
↓
┌────────────────────────────┐
│ Node.js Main Thread │
│ (Event Loop + JS Engine) │
└────────────┬───────────────┘
│
┌─────────────────────────┴────────────────────────┐
│ │
CPU-Bound Work I/O-Bound Work
(e.g., JSON parsing) (e.g., DB, HTTP, FS)
│ │
Handled inline ┌─────┴─────┐
(can block loop!) │ libuv │
| (C++ Library)
| ┌────┴─────┐
| ┌───────────────┴──────────────┐
Worker threads │ Non-Blocking I/O │
in code would create │ (via OS: epoll/kqueue/IOCP) │
OS level threads │ → No threads needed │
└──────────────────────────────┘
┌──────────────┐
│ libuv Thread │ ← For blocking I/O
│ Pool (4) │ ← e.g. fs.readFile
└────┬─────────┘
↓
Result ready → Queued to libuv → Main Thread → Callback
libuv and OS Interaction
- Network I/O → OS event notification (no thread)
- Blocking I/O → libuv thread pool → Callback queue
Think of it as a message queue between OS, libuv, and the main thread.
Can we make every function async ?
Just marking a function async doesn’t make it non-blocking if the code inside is CPU-heavy.
- This still executes on the main Node.js thread and blocks it.
- Use Promises or async APIs only when doing I/O-bound tasks.
- For CPU-heavy tasks, use Worker Threads.
async function cpuHeavy() {
for (let i = 0; i < 1e9; i++) {} // This still blocks
}
const p = new Promise(resolve => {
for (let i = 0; i < 1e9; i++) {} // blocks main thread
resolve('done');
});
CPU-heavy tasks:
- Freeze the event loop
- Delays all requests Need Worker Threads or offloading to other services
What About Deferred Execution Using setImmediate / setTimeout ?
setImmediate() and setTimeout() defer the work, but the work still has to be done by the main thread.
function cpuHeavyNonBlocking() {
setImmediate(() => {
for (let i = 0; i < 1e9; i++) {}
console.log('done');
});
}
Best Practices for Performance
- Use async APIs for all I/O-bound work.
- Avoid synchronous file or network calls in production.
- Offload CPU-heavy tasks to worker threads or separate microservices.
- Monitor event loop delay to detect blocking code.
Conclusion
Node.js is extremely performant for I/O-bound workloads — think APIs, chat apps, streaming, and real-time dashboards. But for CPU-bound tasks, Java or Go often handle load better due to mature multi-threading models.
You can use worker threads or split CPU-heavy workloads into microservices, but Node.js shines most when non-blocking I/O is the primary workload.
Further Reading
About the author
Staff Engineer and technology enthusiast with over a decade of experience in building large-scale distributed systems using Java, Go, and cloud-native technologies. He writes about software engineering, system design, and developer productivity—simplifying complex tech concepts into practical insights for engineers and creators.
Comments
Join the discussionNo comments yet. Be the first to comment.




