I like Node.js, but I don’t think “Node.js is fast and scalable” is a useful reason to choose it for a backend.
Almost every mainstream backend technology can be fast enough when it is used for the right workload and designed properly.
The question I care about is more specific:
Does the way this application spends its time match the way Node.js works?
If the application spends most of its time waiting for databases, HTTP APIs, queues, Redis, object storage and network connections, a Node.js backend can be an excellent fit.
If every request spends several seconds performing heavy calculations on the JavaScript thread, I start asking different questions.
So rather than listing generic advantages and disadvantages, I want to explain where I would actually choose Node.js, where I would be cautious, and where I might use something else entirely.
The Short Version
If I had to summarize my decision quickly, it would look like this:
| Workload | How I View Node.js |
|---|---|
| REST or GraphQL API | Usually a strong fit |
| API gateway / BFF | Strong fit |
| Real-time application | Strong fit |
| Many network/database operations | Strong fit |
| Background jobs mostly waiting on I/O | Good fit |
| CPU-heavy calculations | Needs worker/offloading strategy |
| Video encoding | I would normally use specialized tooling/services |
| Large scientific/numerical workloads | Usually not my first choice |
| Simple site already well served by another stack | Node may add no real advantage |
The technology should fit the workload, team and deployment environment rather than being selected because it is popular.
What Node.js Actually Is
Node.js is a JavaScript runtime built around an event-driven architecture.
It allows JavaScript to run outside the browser and provides platform APIs for things such as:
- HTTP servers
- filesystem access
- streams
- networking
- cryptography
- worker threads
- child processes
- testing
- environment configuration
Node’s event-driven design is especially useful when an application is handling many operations that spend a lot of time waiting rather than continuously calculating.
The official Node.js guide explains this in terms I find useful: Node performs well when the amount of work associated with each client at any particular moment remains small.
You can read the deeper explanation in the official Node.js guide to the Event Loop and Worker Pool.
Use a Supported Node.js Version
Before discussing performance or architecture, I first make sure a production application is running on a supported Node.js release.
At the time I’m updating this article, Node.js 24 is an LTS release while Node.js 26 is the Current release.
For production applications, the Node.js project recommends using an Active LTS or Maintenance LTS version.
Rather than hardcoding an old Node version into a tutorial and forgetting about it, check the official Node.js release schedule.
Where Node.js Fits Naturally: I/O-Heavy Applications
This is the workload I associate most strongly with Node.js.
Imagine an API request that does this:
Receive request
|
v
Query PostgreSQL
|
v
Read Redis
|
v
Call payment API
|
v
Store result
|
v
Return response
Most of that request is not JavaScript continuously using the CPU.
The application is frequently waiting:
- waiting for PostgreSQL
- waiting for Redis
- waiting for the payment provider
- waiting for network packets
That is exactly the kind of workload where asynchronous I/O is valuable.
Start database request
|
+---- database works
|
Node can handle other work
|
v
Database result arrives
|
v
Continue request
The JavaScript thread doesn’t need to sit idle doing nothing while the database server performs its work.
APIs Are One of My Most Common Node.js Use Cases
REST APIs are a natural fit for Node.js because they commonly involve coordinating multiple I/O operations.
A route might look conceptually like this:
app.get(
'/orders/:id',
async (request, response) => {
const order =
await orderRepository
.findById(
request.params.id
);
if (!order) {
return response
.status(404)
.json({
code:
'ORDER_NOT_FOUND'
});
}
response.json(order);
}
);
The important work is normally happening in PostgreSQL rather than inside a CPU-heavy JavaScript loop.
That’s one reason I frequently consider Node.js for:
- REST APIs
- GraphQL APIs
- mobile application backends
- backend-for-frontend services
- API gateways
- integration services
If you’re comparing Node frameworks for this type of application, see my Node.js vs Express guide and NestJS vs Hono comparison.
Node.js Is Also a Good Fit for Backend-for-Frontend Services
A backend-for-frontend, or BFF, sits between a frontend application and other backend services.
React / Mobile App
|
v
BFF
/ | \
/ | \
Users Orders Payments
The BFF often spends most of its time:
- calling services
- combining responses
- performing authentication checks
- transforming data
- returning a frontend-specific response
Again, this is usually much more I/O-heavy than CPU-heavy.
Node.js fits that model nicely.
Real-Time Applications Can Be Another Strong Fit
Node’s event-driven architecture also makes it attractive for applications maintaining many active connections.
Examples include:
- chat
- live dashboards
- collaboration features
- notifications
- presence systems
- live status updates
A WebSocket-oriented application may look like this:
Client A ----\
Client B -----\
Client C ------> Node.js server
Client D -----/
Client E ----/
|
+---- Redis
|
+---- database
|
+---- message broker
But I still wouldn’t say “use Node because it supports WebSockets.”
Many platforms support WebSockets.
The broader advantage is that Node’s programming model works naturally with applications handling a large amount of asynchronous network activity.
Streams Are Another Area Where Node.js Feels Natural
Node.js has first-class stream APIs.
Instead of loading an entire large file into memory before sending it, I can process it incrementally.
Large file
|
v
Small chunk
|
v
Small chunk
|
v
Small chunk
|
v
Destination
For example:
import {
createReadStream
} from 'node:fs';
const stream =
createReadStream(
'./large-report.csv'
);
stream.pipe(response);
Streaming can be useful for:
- large file downloads
- CSV processing
- proxies
- data transformation
- upload pipelines
The main benefit is avoiding unnecessary full-buffer operations when the data can be handled incrementally.
Using JavaScript or TypeScript Across the Stack Is Useful—but I Wouldn’t Oversell It
One genuine advantage for some teams is using JavaScript or TypeScript on both the frontend and backend.
React / Next.js
|
JavaScript / TypeScript
|
v
Node.js backend
This can make it easier for developers to move between parts of the application and can allow some contracts, tooling and validation schemas to be shared.
But I don’t interpret “same language” as “share everything.”
I wouldn’t expose database models to the frontend just because both sides use TypeScript.
Database model
|
v
Backend domain
|
v
API contract
|
v
Frontend
The backend/frontend boundary still matters.
If you’re using TypeScript with Node, I cover the setup in my production Node.js TypeScript guide.
npm Is an Advantage and a Responsibility
The Node.js ecosystem makes it very easy to add functionality.
npm install library-name
That productivity is useful.
It also means I need discipline around dependencies.
Before adding a package, I ask questions such as:
- Do we actually need it?
- Can Node already do this?
- Is the package maintained?
- How large is its dependency tree?
- Does it have known security issues?
- Would replacing it later be difficult?
I don’t consider “npm has a package for everything” to be an automatic reason to install a package for everything.
Modern Node.js already provides functionality such as:
fetch()- UUID generation through
crypto - environment-file support
- testing
- Web Streams APIs
I prefer checking the platform first before adding another dependency.
The Biggest Node.js Limitation I Watch: Blocking the Event Loop
This is much more important to me than the phrase “Node.js is single-threaded.”
JavaScript callbacks normally execute on the Event Loop.
If one callback spends too long doing synchronous CPU work, other work waiting for the Event Loop doesn’t get its turn.
Request A
|
v
Heavy CPU calculation
|
| Event Loop busy
|
+------ Request B waiting
|
+------ Request C waiting
|
+------ Request D waiting
This is where a Node.js application can look fast under normal testing and then behave badly when one expensive operation appears in production.
A Simple Example of Blocking Work
This route is deliberately bad:
app.get(
'/calculate',
(request, response) => {
let total = 0;
for (
let index = 0;
index < 5_000_000_000;
index++
) {
total += index;
}
response.json({
total
});
}
);
While that loop is executing, the JavaScript thread can’t simply jump into another normal callback and continue handling it.
This is why the Node.js documentation emphasizes keeping Event Loop callbacks small and avoiding blocking operations.
“Node.js Is Single-Threaded” Is an Incomplete Explanation
I still hear this phrase used as if the entire Node.js process contains exactly one thread and can never do anything in parallel.
That’s not accurate.
There are several things happening in a Node.js application:
Node.js process
|
+--- JavaScript Event Loop
|
+--- Worker Pool
|
+--- Worker Threads if created
|
+--- OS / networking operations
The important architecture constraint is that normal JavaScript execution on the main Event Loop should not be occupied by long-running synchronous work.
Worker Threads Change the CPU-Heavy Conversation
Node.js provides the node:worker_threads module for JavaScript work that benefits from parallel execution.
The official documentation specifically recommends Worker Threads for CPU-intensive JavaScript and notes that they generally don’t help much with I/O-intensive work.
Conceptually:
Main Node.js thread
|
+---- HTTP requests
|
+---- database I/O
|
+---- worker pool
|
v
CPU-heavy work
A very simplified worker example looks like this:
import {
Worker
} from 'node:worker_threads';
const worker = new Worker(
new URL(
'./calculation-worker.js',
import.meta.url
),
{
workerData: input
}
);
worker.on(
'message',
result => {
console.log(result);
}
);
The calculation runs in another worker instead of occupying the main JavaScript thread.
For repeated CPU jobs, I wouldn’t normally create a brand-new Worker for every tiny operation. A worker pool can avoid repeatedly paying worker startup overhead.
You can read the current API in the official Worker Threads documentation.
But Worker Threads Don’t Mean Node Is Automatically Ideal for Every CPU Workload
This is where I think architectural judgment still matters.
Yes, Node.js can use workers.
That doesn’t automatically mean I would choose Node.js for an application whose main purpose is:
- video encoding
- scientific computation
- large numerical simulations
- CPU-heavy machine-learning inference
- heavy image transformation
- long-running compression/encryption workloads
I might still use Node.js for the API around those workloads.
Client
|
v
Node.js API
|
v
Job queue
|
v
Specialized worker/service
|
v
Result storage
That is often a much cleaner separation.
Offloading CPU Work Can Be Better Than Forcing It into the API Process
Suppose users upload videos.
I wouldn’t normally make the HTTP request wait while the web process converts the entire video.
I would consider a workflow like:
Upload video
|
v
Node.js API
|
+--- store upload
|
+--- create job
|
v
Return job ID
Meanwhile...
Queue
|
v
Media worker
|
v
Encode video
|
v
Store result
Now the HTTP application stays responsive while specialized workers handle expensive background work.
This isn’t only a Node.js pattern. It’s a general distributed-system pattern, but it becomes particularly useful when protecting a Node.js Event Loop from long CPU-bound tasks.
Node.js Doesn’t Make a Slow Database Fast
This is another reason I’m cautious with the phrase “Node.js is fast.”
Imagine this request:
Node routing 1 ms
Authentication 3 ms
Database query 850 ms
Serialization 4 ms
The problem isn’t the runtime.
The database query needs attention.
The same applies to:
- poor database indexes
- N+1 queries
- slow third-party APIs
- huge payloads
- network latency
- bad caching decisions
Choosing Node.js does not remove normal backend performance engineering.
Callback Hell Is Not a Reason I Avoid Node.js Today
This is one area where the original version of this article felt dated.
Older Node.js code often looked like:
getUser(id, (error, user) => {
getOrders(
user.id,
(error, orders) => {
getPayments(
orders,
(error, payments) => {
// ...
}
);
}
);
});
That nesting is difficult to read.
Modern application code can normally use Promises and async/await:
const user =
await getUser(id);
const orders =
await getOrders(
user.id
);
const payments =
await getPayments(
orders
);
There are still plenty of ways to write difficult asynchronous code, but “callback hell” is not the central reason I would reject Node.js for a modern backend.
Async/Await Does Not Make Sequential I/O Automatically Good
Readable asynchronous code can still be inefficient.
Consider:
const profile =
await loadProfile();
const orders =
await loadOrders();
const notifications =
await loadNotifications();
If those operations don’t depend on one another, we’ve created a waterfall.
Profile
|
v
Orders
|
v
Notifications
I may be able to run them concurrently:
const [
profile,
orders,
notifications
] = await Promise.all([
loadProfile(),
loadOrders(),
loadNotifications()
]);
Now:
+--- Profile
|
Start+--- Orders
|
+--- Notifications
I only do this when the operations really are independent and the downstream systems can safely handle the concurrency.
More parallel requests are not automatically better either.
Error Handling Is an Architecture Problem, Not a Node.js Deal-Breaker
Another challenge in the old article was “Node.js error handling.”
I don’t consider asynchronous errors a reason to avoid Node.js.
I do consider consistent error handling essential.
For an API, I normally want something like:
Route
|
v
Service
|
v
Known error
|
v
Central error handler
|
v
Consistent HTTP response
Operational errors such as invalid input, missing data and dependency timeouts should be handled intentionally.
Unexpected programming errors should be logged and investigated rather than silently converted into meaningless success responses.
I cover this in detail in my production Node.js error handling guide.
Horizontal Scaling Still Matters
One Node.js process does not need to represent the entire capacity of an application.
A stateless HTTP service can run as several processes or containers:
Load Balancer
/ | \
v v v
Node A Node B Node C
\ | /
\ | /
Database
This is how I usually think about scaling an API: not “how do I make one JavaScript process handle the entire world?” but “how do I design the application so instances can be added safely?”
That means thinking about things such as:
- stateless application instances
- shared Redis/session storage where needed
- database connection limits
- queues
- load balancing
- idempotency
- graceful shutdown
If you’re designing distributed services, my Node.js microservices guide goes deeper into these concerns.
When I Would Not Choose Node.js Just for the Sake of It
Sometimes the strongest reason not to use Node.js has nothing to do with the Event Loop.
The existing system may already have a stack that works.
Imagine a mature PHP application with:
- experienced PHP developers
- stable deployment infrastructure
- working monitoring
- known operational procedures
- a large amount of tested domain logic
I wouldn’t rewrite that application in Node.js merely because JavaScript is popular.
A technology migration needs to solve a real problem.
Otherwise we exchange known problems for new problems while paying the cost of a rewrite.
Team Experience Is Part of the Architecture
I don’t choose technology in isolation from the people who need to maintain it.
If a team deeply understands Java and Spring, there needs to be a meaningful reason to replace that experience with Node.js.
The same is true in the other direction.
A team of experienced TypeScript developers may be able to deliver a Node.js backend more safely and quickly than a stack they have barely used.
I consider:
- existing expertise
- hiring
- operational knowledge
- library maturity for the domain
- deployment experience
- long-term maintenance
A theoretically perfect runtime is not very useful if nobody on the team can operate it safely.
Node.js vs Go, Java and PHP: How I Think About It
I don’t think a simple “which language is fastest?” comparison is useful.
My rough thinking looks more like this:
| Technology | Where I Often Consider It |
|---|---|
| Node.js / TypeScript | APIs, BFFs, real-time systems, JavaScript-heavy teams, integration services |
| Go | Small efficient services, networking, infrastructure tools, CPU/concurrency requirements where Go fits the team |
| Java / JVM | Large enterprise systems, mature ecosystems, complex long-lived backend platforms |
| PHP | Web applications, CMS/e-commerce ecosystems, existing PHP teams and platforms |
Those are not rules.
You can build excellent APIs in all four.
I look at the workload, ecosystem, existing system and team rather than trying to declare one universal backend winner.
When Node.js Is a Strong Choice for Me
I become more confident about choosing Node.js when several of these are true:
- The application is primarily I/O-heavy.
- It talks to several APIs or services.
- It needs real-time communication.
- It is an API gateway or BFF.
- The team already works comfortably with TypeScript.
- The Node ecosystem has mature libraries for the problem.
- CPU-heavy jobs can be handled separately.
- The service can scale horizontally.
When I Become More Cautious
I ask more questions when several of these are true:
- The application’s main job is heavy computation.
- Large synchronous operations are unavoidable.
- The team has little Node.js experience.
- A mature existing stack already solves the problem well.
- The domain depends heavily on another ecosystem.
- The system would require complicated worker infrastructure just to compensate for the chosen runtime.
Node.js may still work in those situations.
The point is that I want a reason for choosing it.
Questions I Ask Before Choosing Node.js
This is the checklist I find more useful than a generic advantages list:
- Is the workload mainly I/O-bound or CPU-bound?
- How many external services does the application call?
- Does the application need long-lived network connections?
- Could expensive work block the Event Loop?
- If CPU-heavy work exists, where will it run?
- Does the team already know TypeScript and Node.js?
- Does the ecosystem provide mature tools for this domain?
- How will the service scale?
- What is our monitoring strategy?
- How will we manage dependencies and security updates?
- Are we choosing Node because it solves a problem or because it is familiar?
My Node.js Backend Production Checklist
If I do choose Node.js, I also want the production basics covered.
- supported Node.js LTS version
- request validation
- centralized error handling
- structured logging
- timeouts on network calls
- controlled retries
- database connection pooling
- graceful shutdown
- health/readiness checks
- metrics and alerting
- Event Loop blocking kept under control
- background jobs isolated where appropriate
- dependency/security review
- CI tests and type checking
Choosing Node.js is the beginning of the architecture decision, not the end of it.
What I No Longer Consider a Useful Node.js “Advantage”
There are a few claims I would avoid when explaining Node.js today.
“Node.js is blazing fast.”
Too vague.
Fast at what workload, compared with what, using which framework, database and architecture?
“Node.js automatically scales.”
No runtime automatically fixes database bottlenecks, shared state, bad queries or poor architecture.
“JavaScript everywhere means code reuse everywhere.”
Some contracts and utilities can be shared. Frontend and backend code still have different security and runtime responsibilities.
“Node.js can’t do CPU work because it is single-threaded.”
Too simplistic.
Worker Threads exist specifically for CPU-intensive JavaScript. The real question is whether Node is the best overall architecture for a workload dominated by CPU work.
“Callback hell is Node.js’s biggest problem.”
That description belongs much more to older callback-heavy Node.js code than to the way I would write a modern application with Promises and async/await.
Final Thoughts
I use Node.js because it is a good fit for a lot of the backend work I care about—not because I think it is the best runtime for every problem.
For APIs, integration services, backend-for-frontend systems and applications that spend most of their time waiting on I/O, Node’s event-driven model can be a very natural fit.
When the workload becomes heavily CPU-bound, I either isolate that work with workers or queues, use specialized tooling, or ask whether another technology would be a better fit for that particular service.
That’s the distinction I think matters.
The goal is not to prove that Node.js can technically do everything.
The goal is to choose an architecture that makes the system easier to build, operate and maintain.
I choose Node.js when its asynchronous model matches the problem. I don’t choose it simply because JavaScript is already installed on my laptop.
Continue Learning
If you’re building Node.js backends, these guides continue from the same practical approach:
- Node.js vs Express: Runtime, Framework, and When You Need Each
- Set Up a Production Node.js Project with TypeScript
- Error Handling in Node.js: Production Best Practices
- Building Microservices with Node.js: What Actually Matters in Production
- Node.js Libraries I Actually Use for Production Development



