In early 2026, our team made a choice that surprised our platform engineering peers: we migrated a high-traffic internal API from Node.js to PHP. This is not a “Node.js is bad” essay. It is leaving Node.js for PHP: internal API migration lessons from a real event-loop bottleneck, and it explains why PHP’s simpler threading model solved a problem that more Node.js worker threads could not. For our internal API, the issue was never syntax or language popularity. It was concurrency architecture.
Where the Event-Loop Bottleneck Actually Started
Our internal API was a typical Express service. It authenticated service tokens, fetched tenant configuration, applied role-based filters, and performed data enrichment for downstream systems. At 120 requests per second, the Node.js service was fine. The event loop hummed along, callbacks were scheduled cleanly, and p99 latency stayed under 100 milliseconds. At 300 requests per second, the same service began to fall apart.
The root cause was not I/O. The event loop could handle thousands of database queries and HTTP calls without breaking a sweat. The problem was a subset of endpoints that mixed request handling with CPU-heavy work: JSON schema validation, CSV export generation, permission graph expansion, and cryptographic signature verification. Those tasks monopolized the JavaScript thread, and every other request waiting for the event loop had to pay the price.
The “Everything Is Async” Trap
Node.js’s event loop is excellent at juggling non-blocking I/O. But CPU-bound work is a different monster. Every async function still executes on the same JavaScript thread; async only defers work, it does not parallelize it. We wrapped everything in promises, but the event loop was still the single point of failure. When a request triggered a 400 ms CPU-bound transformation, the next 200 requests waited behind it.
Worker Threads Made It Better, Then Worse
We tried the standard Node.js answer: worker threads. We moved image resizing, pako compression, and PDF generation into a dedicated worker pool. That helped at first, but it introduced a new set of operational problems. We had to tune the thread pool size, handle serialization overhead, manage shared memory, and reason about backpressure. One unhandled worker failure could poison the entire pool. More importantly, adding worker threads made the event loop more available, but it did not make our concurrency model more predictable.
Why PHP’s Simpler Threading Model Was the Right Fix
When we evaluated alternatives, we kept coming back to one boring fact: PHP-FPM does not have an event loop. PHP’s concurrency model is much simpler. Each request is handled by an isolated worker process that runs a script to completion. If one request does a heavy CPU task, it occupies only that worker. The other workers continue serving requests without waiting for the same global loop.
This is why “PHP’s simpler threading model” is a useful phrase, even though PHP does not use user-space threads by default. The model is process-per-request, and it is already the thing we needed. We replaced our event-loop bottleneck with Nginx and PHP-FPM workers, and the change felt like taking a deep breath after a long sprint.
Process Isolation Beats Shared-State Coordination
In our Node.js service, every request shared the same event loop and the same process memory. That made it easy to introduce accidental shared state. In PHP-FPM, each worker has its own memory space. A fatal error in one request does not corrupt the next request. There are no unhandled promise rejections eating an entire worker pool. The operating system scheduler handles context switching, and the PHP process simply starts a new worker when one exits.
Blocking I/O Is Not a Sin in PHP
The Node.js community often treats blocking I/O as a design flaw. But inside PHP-FPM, blocking I/O is just normal behavior. When a PHP function waits for a database result, that worker is idle, but the other workers are unaffected. We no longer needed to write convoluted callback chains or promise orchestration just to avoid stalling the event loop. We could write straightforward, sequential code, and the PHP-FPM worker pool absorbed the concurrency.
Predictable Capacity Planning
With Node.js, we had to model the event loop, the libuv thread pool, and the worker thread pool simultaneously. With PHP-FPM, capacity planning became much simpler. We measured the average memory footprint per PHP worker, divided available RAM by that number, and set pm.max_children accordingly. We also set pm.max_requests to gracefully recycle workers and prevent memory leaks from lingering. The mental model is boring, and boring is what an internal API needs.
Internal API Migration Lessons We Learned
The migration was not a line-by-line rewrite. We mapped every endpoint into three buckets before writing any PHP code:
- I/O-bound and streaming endpoints: stayed in Node.js.
- CPU-heavy or synchronous blocking endpoints: moved to PHP.
- Endpoints that touched the same database or auth context: moved together to avoid cross-runtime calls.
Port the Workload, Not the Architecture
Our first instinct was to replicate the Express middleware chain in PHP. That was a mistake. We ended up using a small Slim-based API with a few focused middleware layers. PHP 8.4’s typed properties and readonly classes made the domain layer clean, but we deliberately avoided forcing JavaScript patterns into PHP. The goal was to serve the same contract, not to recreate the same implementation.
Move Heavy Work Out of the Request Path When Possible
PHP-FPM’s process model gives you more headroom for expensive synchronous work, but it is still better to run long jobs in a queue. We used a simple database-backed job queue for the most expensive operations. However, the important difference was this: when a job had to run synchronously because the caller needed the result immediately, we no longer worried about taking down every other request. A single PHP worker could spend 600 milliseconds on a task while other workers handled normal traffic.
Watch Worker Memory, Not Event-Loop Lag
Our monitoring dashboard changed completely. Instead of charting event-loop delays and libuv thread pool utilization, we watched PHP-FPM worker counts, memory usage, and queue length. The alerting was simpler. We no longer received pages about high event-loop lag during a database query storm. We only saw a predictable increase in worker usage, which scaled horizontally with Nginx upstreams.
What We Lost—and What We Did Not Miss
Leaving Node.js is not free. We lost the convenience of sharing validation code with our frontend JavaScript. We also had to rework a few utility packages that did not have exact PHP equivalents. And if the API had been built around WebSockets or streaming large files, Node.js would have remained the better choice.
But for this internal API, those things did not matter. Our callers were other internal services, not browsers. The API did not need persistent connections. JSON parsing, HTTP routing, and database access are all perfectly fast in PHP. And the things we thought we would miss from Node.js—live reload, the npm ecosystem, JavaScript’s object literal shorthand—turned out to be minor compared to the operational stability we gained.
Should You Make the Same Move?
An event-loop bottleneck is not automatically a reason to switch languages. If your Node.js service is small, mostly I/O-bound, and you have healthy observability into event-loop delays, you may not need to migrate. But if your internal API has become a dumping ground for CPU-bound endpoints, and worker threads are adding more complexity than they remove, PHP’s simpler threading model is worth a serious look.
Conclusion
In the end, leaving Node.js for PHP was less about escaping JavaScript and more about choosing a concurrency model that matches how internal APIs actually fail. PHP’s simpler threading model—isolated workers, no shared event loop, and no CPU-bound starvation—gave us a stable, predictable service and reduced our incident count. We no longer debug event-loop stalls. We just watch worker health and memory. That is a trade we would make again.
