Two models, one fork
Threading, speed under load, memory, and real-time capability are not four separate facts. Each one falls out of this single choice.
PHP vs JavaScript for web development, decided by execution model
Skip the feature checklist. PHP and JavaScript differ in one thing that causes everything else: PHP is share-nothing, one request per PHP-FPM worker; JavaScript on Node is one thread and one event loop that interleaves thousands of requests by never blocking on I/O. Threading, speed under load, memory, and real-time capability all fall out of that fork. Here is the model, the worked req/s numbers, and a per-layer way to choose.
Two execution models, one root cause#
Most write-ups on PHP vs JavaScript for web development arrive as a feature checklist. They compare syntax, list "PHP is server-side, JavaScript is client-side", and chart popularity. Yet none of that predicts how either one behaves under real load. The behaviour comes from one place, and both languages made an opposite choice there.
That place is the concurrency model. In short, how does the runtime handle a second request while the first is still waiting on a database? PHP answers with more processes. JavaScript answers with one process that never waits. Hold those two answers side by side and every downstream difference becomes predictable.
PHP: share-nothing, one request per worker#
PHP runs behind PHP-FPM, a pool of worker processes. Because each worker handles exactly one request at a time (see the PHP-FPM manual), the pool size is your concurrency limit. That size is capped by pm.max_children (documented in the FPM configuration reference). Meanwhile a worker that is waiting on a slow query is simply busy. It blocks, and that is fine, because it owns nothing anyone else needs.
The defining trait is in the name. Workers share nothing. When a request ends, its memory is reset (the PHP garbage-collection manual covers the cleanup), so a leak or a crash dies with the worker instead of poisoning the next request. This is crash isolation by construction, not by discipline.
JavaScript: one thread, one event loop, non-blocking I/O#
Node runs your JavaScript on a single thread with one event loop (the runtime's own guide, "Don't Block the Event Loop", is the canonical read). Instead of parking a whole process on a slow query, Node hands the wait to libuv and moves on. Because the query is a non-blocking await, the same thread immediately picks up another request. As a result, one process interleaves thousands of requests that are all waiting at once.
That is the strength and the trap in one sentence. The thread is never idle during I/O, so throughput is high. Yet the thread is also singular, so any code that refuses to yield freezes everything. The ordering of these callbacks is worth knowing, and the event-loop timers guide walks it precisely.
Every difference is a consequence of that one choice#
Here is the payoff of naming the fork. The differences other pages list as independent facts are all downstream of it. Walk the chain once and you can predict behaviour you have never benchmarked.
The fork
PHP scales concurrency with more processes. Node scales it with one process that never blocks on I/O.
Threading falls out
A fixed PHP worker pool versus overlapping awaits on a single Node thread. Neither is a separate design decision. Each is the fork, seen from the threading angle.
Speed under load falls out
PHP throughput is workers divided by request time. Node throughput is concurrency from non-blocking I/O. Same fork, seen from the throughput angle.
Threading and concurrency#
PHP concurrency is the worker count. Because a busy worker cannot take a second request, you raise concurrency by adding workers. Node concurrency is the number of awaits in flight. Because the thread yields on every await, one process holds many requests at once. In contrast, PHP would need one worker per simultaneous request.
Speed under load#
Speed is where the two models diverge most visibly. For I/O-bound work, both scale, but they pay differently. PHP pays in processes and RAM. Node pays in nothing extra, until the work stops being I/O-bound. Then the single thread becomes the ceiling, and we will make that failure concrete below.
Memory behavior#
Memory behaviour is a direct read of the fork. PHP state is cold and per-request, so a leak dies at request end. Node state is warm and long-lived, so a leak accumulates across requests until the process restarts. Moreover the pool model has a fixed cost even at idle, because every worker holds its own interpreter and connections.
Show data table
| Item | Resident memory |
|---|---|
| PHP-FPM pool (40 workers) | 1,600 MB |
| Node (1 process) | 80 MB |
The PHP pool trades roughly 1.6 GB of steady RAM for its crash isolation; the Node process holds far less because concurrency lives in one heap, not 40 interpreters.
Neither number is a verdict on its own. The pool RAM buys crash isolation and simple mental models. The single heap buys density, but it asks you to watch for leaks that never used to matter in PHP. Therefore the memory difference is a trade, not a winner.
The blocking-call failure mode#
This is the demo that teaches the whole model. Drop a synchronous 200 ms operation into a Node request handler and watch what happens to everyone else. Then drop the identical call into PHP and watch how little happens. The two snippets are the same work in two execution models.
// Express handler with ONE synchronous 200 ms operation in it.
app.get('/report', async (req, res) => {
const rows = await loadRows(req.query.id) // I/O: an await, off-thread
const summary = summariseSync(rows) // 200 ms of pure CPU, no await
res.json(summary)
})
// summariseSync burns the ONE JS thread for 200 ms. While it runs, the event
// loop cannot advance, so EVERY other in-flight request is frozen until it
// returns. One slow request just became everyone's slow request. The fix is to
// offload the CPU work to a worker thread, not to add more app instances. <?php
// The identical 200 ms of CPU work, inside a PHP-FPM request.
function report(): array {
$rows = load_rows($_GET['id']); // I/O: the DB wait
return summarise_sync($rows); // 200 ms of pure CPU
}
// This worker is busy for the full 200 ms. But it is ONE worker out of the
// pool. The other workers keep serving their own requests, each in its own
// process with its own memory. share-nothing means one slow request can never
// reach into another. The cost is contained by construction. Because Node has one thread, the synchronous call blocks the event loop, so every concurrent client waits behind it. Because PHP is share-nothing, the same call keeps one worker busy while the rest of the pool serves normally. This single contrast is the reason "which is faster" has no answer without naming the workload. On I/O-bound work the loop wins on density. On CPU-bound work the share-nothing pool wins on isolation. The fix on Node is worker_threads, which moves the burn off the main thread.
Concurrency with real numbers#
Talk is cheap, so make the model playable. The simulator below runs the same load through both execution models and shows the throughput, the backlog, and the memory each one pays. Set the concurrency and the request time, then flip the work type and watch the Node lane change character completely.
Play the model: workers vs the loop#
Start with the worked example: 1000 connections, a 180 ms request, non-blocking I/O. First read the PHP-FPM lane, then read the Node lane. Now flip the toggle to blocking CPU. The share-nothing lane barely moves, while the event-loop lane collapses. That flip is the entire argument, made operable.
PHP-FPM (share-nothing)
- running at once
- 40 of 40 workers
- backlog queued
- 960
- pool ceiling
- 222 req/s
- pool memory
- 1.56 GB
Each worker blocks through its own I/O wait. Concurrency costs workers, and workers cost RAM.
Node (single-thread event loop)
- interleaved now
- 1,000 in one process
- stalled behind a block
- 0
- slowest client waits
- 180 ms
- processes
- 1
Every await hands the thread back, so one process interleaves all of them. Memory scales with in-flight state, not processes.
| Model | I/O-bound (await) | CPU-bound (blocking) |
|---|---|---|
| PHP-FPM, 40 workers | ~222 req/s, ~1.6 GB, 960 queued | ~222 req/s, cost isolated per worker |
| Node, 1 process | interleaves all 1000, ~180 ms each | ~5.5 req/s, 999 clients stalled |
The one axis: PHP-FPM ceiling is workers x 1000 / request_ms. Node interleaves non-blocking work in a single thread, but one blocking call there stalls every concurrent client until it is offloaded.
I/O-bound is fine for both; CPU-bound is where the loop breaks#
The felt gap is worth stating on its own. Take one Node process at 1000 connections and a 180 ms request. Non-blocking, it interleaves all of them. Blocking, one request owns the thread and the rest queue. Here is the same process, one toggle apart.
~5,556 req/s
Non-blocking I/O (await)
~5.5 req/s
Blocking CPU (synchronous)
One blocking call turns a healthy interleave into a near-frozen queue; the same call on PHP-FPM only ties up the single worker running it.
| Option | Illustrative req/s for a single Node process at a 180 ms request: a non-blocking await versus a synchronous CPU block that owns the only thread. |
|---|---|
| Non-blocking I/O (await) | ~5,556 req/s |
| Blocking CPU (synchronous) | ~5.5 req/s |
The per-layer decision framework#
Now convert the model into judgement. The real question in PHP vs JavaScript for web development is never the whole product; it is each layer. Therefore the mistake is choosing one language for everything. Instead, choose per layer, because each layer has its own I/O and concurrency shape. The table below maps common workloads to a default, and it names the execution-model reason for each. Treat the reason as the rule; the recommendation is just its shortcut.
| Workload layer | Better default | Why, in execution-model terms |
|---|---|---|
| Content site / CMS | Better defaultPHP | Why, in execution-model termsRequest-per-page reads suit share-nothing workers, and the CMS ecosystem is deepest here. |
| CRUD JSON API | Better defaultEither | Why, in execution-model termsPure I/O-bound DB calls fit both models; decide on team skills and the rest of the stack. |
| Real-time / WebSockets | Better defaultJavaScript (Node) | Why, in execution-model termsOne event loop holds thousands of long-lived connections; PHP-FPM would pin one worker per socket. |
| Streaming / long-lived responses | Better defaultJavaScript (Node) | Why, in execution-model termsNon-blocking streams ride one thread; a worker-per-stream pool exhausts its process count fast. |
| CPU-bound jobs | Better defaultPHP, or an offloaded worker | Why, in execution-model termsshare-nothing isolates the burn per worker; on Node it must go to worker_threads or a queue. |
| Shared full-stack logic | Better defaultJavaScript / TypeScript | Why, in execution-model termsOne language across client and server removes a whole translation and duplication layer. |
The 2026 caveat: the line is blurring#
The historic split of "PHP for the backend, JavaScript for the frontend" is dissolving, and honesty demands saying so. On the PHP side, FrankenPHP, Swoole, and RoadRunner run long-lived PHP processes that hold state between requests. That erases part of the share-nothing story on purpose, in exchange for the density Node has. PHP even ships fibers now for cooperative concurrency, documented in the fibers manual.
On the JavaScript side, Node, Deno, and Bun are full runtimes, not just a frontend tool. Furthermore, shared TypeScript across client and server is now a genuine reason to standardise on one language. The spec that governs how that code executes is the ECMAScript execution-contexts chapter. If you are weighing that full-stack move, our take on the ECMAScript 2026 language features covers what modern JavaScript actually gives you.
When NOT to reach for each#
Every honest comparison needs an anti-recommendation, so here is the short version. Reach past the obvious default when the workload argues against it.
How to choose PHP vs JavaScript for web development, layer by layer#
Turn the model into a repeatable procedure. First, for each layer, ask one question. Is this work I/O-bound or CPU-bound, and how many things happen at once? Second, match the answer to an execution model. I/O-bound with high concurrency favours the event loop. CPU-bound or crash-sensitive work favours share-nothing isolation. Third, only then pick the language, because the runtime follows from the workload, not the other way around.
That is how to approach PHP vs JavaScript for web development without a religious war. You are not ranking languages. You are matching each layer to the concurrency model that serves it. For instance, a content CMS in PHP can sit happily beside a real-time notifications service in Node, and both choices are correct for the same product. The related framework debate plays out in WordPress vs Laravel in 2026, and the edge-runtime side shows up when you render a React app on Cloudflare.
If you are drawing this line for a real product and want a second opinion grounded in how the work actually runs, we do exactly this kind of architecture review. No pressure and no lock-in.
See how we approach software engineering