I moved my primary stack from PHP to Node in 2014. At the time that was a real bet, not an obvious one. Server-side JavaScript was still something you defended in meetings.
Five years on it’s the default, and the reasons people give for why are mostly the wrong ones.
“One language everywhere” is the least interesting part
The pitch was always that you stop context-switching between PHP on the server and jQuery on the client. That’s true and it’s minor. Switching languages costs you a few minutes; switching architectures costs you months.
The thing that actually mattered is that the concurrency model is different in a way that changes how you design.
PHP’s model is process-per-request. Every request gets a fresh interpreter, does its work, and dies. That’s beautifully simple and it means you cannot hold anything in memory between requests. Want a connection pool? That’s a separate daemon. Want an in-process cache? Doesn’t exist, use Redis. Want a long-lived websocket? Wrong tool entirely.
Node’s event loop is one process handling thousands of concurrent connections, none of which block on I/O. That’s not a performance detail. It means state can live in your process, which means whole categories of architecture become available that were previously somebody else’s infrastructure.
The failure mode nobody puts in the tutorial
Non-blocking I/O is only non-blocking for I/O.
The event loop is a single thread. Any CPU-bound work you do on it blocks every other connection for the duration. Not degrades. Blocks. A synchronous JSON parse of a large payload, a bcrypt round with the cost factor set high, an unbounded regex against user input, a JSON.stringify on something enormous. Each of those stops the world.
This is head-of-line blocking. The name is useful because it tells you where to look. In PHP’s process-per-request model a slow request is slow for one user. In Node a slow synchronous operation is slow for everyone currently connected, because they’re all queued behind it on the same thread. That inverts your intuition about diagnosing latency: the request that’s slow is usually not the request that caused it.
The mitigations aren’t exotic. Move CPU work off the loop into worker threads or a queue. Never write a regex that can backtrack catastrophically against input you don’t control. Treat any synchronous call in a request path as a bug until proven otherwise. But you have to know to look, and coming from a process-per-request background you won’t.
What npm actually bought
Shared code between client and server is the stated benefit and it’s real: validation logic, type definitions, and utilities living in one place instead of being reimplemented and drifting.
The unstated benefit is bigger: a single dependency graph means one place to audit, one lockfile, one upgrade path. On a large codebase that’s worth more than the code sharing, because dependency drift across two ecosystems is a slow tax you pay forever.
The unstated cost is also real, and node_modules is only the visible part. A single registry with low publishing friction means the median dependency is less vetted than what you’d have pulled from Maven or PECL. That’s a tradeoff worth making with your eyes open rather than by default.
Where it stands
TypeScript on both sides, sharing interfaces between API and client, one test runner for everything. That combination cut my delivery time meaningfully and I’d make the same call again.
The deeper pattern took longer to see. The reason a long-lived process was worth switching languages for is the same reason I later built nSelf as a persistent stack rather than a deploy script, and the same reason Cascade runs a daemon instead of reconstructing state per invocation. Where state is allowed to live determines what you can build on top of it. That’s been the most durable architectural lesson of my career, and I first learned it here.
Callback hell was genuinely bad before async/await, and anyone who tells you it wasn’t has forgotten. But the model underneath was always sound. The syntax caught up.