Development

Node.js vs PHP: The Difference Is What Survives a Request

Performance, ecosystem, hiring, and deployment compared without the fanboyism

PHP wipes memory after every request; Node keeps one heap alive for all of them. That single difference explains the leaks, the crashes and the bootstrap cost — and worker-mode PHP has now moved the line.

This comparison is usually run as a language argument, which is why it never resolves. The difference that actually decides your architecture is not syntax or benchmark throughput — it is what happens to memory between two requests. Get that straight and most of the rest follows.

It is also the part of the comparison that changed most in the last few years, in both directions, and most published comparisons are working from a picture that was accurate in about 2016.

The real difference: what survives a request

PHP's traditional model is shared-nothing. A request arrives, PHP-FPM hands it to a worker, the script runs from scratch, and when it finishes the worker's memory is wiped. Nothing carries over.

Node runs one long-lived process with an event loop. Your application is booted once and stays in memory. Every request is handled by the same process, sharing the same heap.

Two lanes comparing memory models. PHP-FPM shared-nothing: each of three requests boots the application from scratch and has its memory freed, so a leak is bounded and a fatal error kills one request, but the bootstrap cost is paid every time. Node: one startup boots the application once and stays resident, all requests share the same heap and event loop, and only a restart reclaims a leak, so a leak accumulates and one unhandled rejection takes every in-flight request — but there is no per-request bootstrap. A panel notes that FrankenPHP worker mode, Swoole and RoadRunner move PHP to the right-hand side.
Neither is better. One is safer by default, one is faster by default, and worker-mode PHP now lets you choose which trade you want.

That single difference produces most of the practical consequences:

  • A memory leak in PHP is bounded. Whatever you leak is freed at the end of the request. The same leak in Node accumulates until the process is restarted or the machine runs out.
  • A fatal error in PHP kills one request. In Node, an uncaught exception or an unhandled promise rejection takes down the process and every in-flight request with it.
  • PHP pays a bootstrap cost per request — autoloading, container construction, config parsing — that Node pays once at startup.
  • Node can hold state between requests. A warm cache, an open WebSocket, a connection pool. PHP has to reach outside the process for all of that, which is why Redis is so ubiquitous in PHP stacks.

Neither is better. One is safer by default, one is faster by default, and the trade is explicit.

Except the line has moved

Two developments have blurred this considerably, and a comparison that ignores them is describing the past.

PHP can now stay resident. FrankenPHP's worker mode, Swoole and RoadRunner all boot your application once and keep it in memory across requests. You get Node's performance profile — and Node's footguns. State that used to be wiped now persists, so a static property holding request data becomes a cross-request data leak, and a database handle has to be checked rather than assumed fresh. Laravel Octane exists to manage exactly these hazards. If you adopt worker mode, you have adopted the long-running process model and its discipline, whatever language you wrote it in.

PHP got real concurrency. Fibers landed in 8.1, and libraries built on them let you run concurrent I/O without the callback structure that defined async PHP attempts before. It is not as mature as Node's ecosystem here and probably never will be, but "PHP cannot do concurrency" stopped being true.

Meanwhile Node has grown in the other direction — worker threads for CPU work, and a type system, in practice, because serious Node is TypeScript now. Which is worth saying plainly: comparing PHP to JavaScript is no longer the comparison. It is PHP 8.4 against TypeScript, and both have enums, union types, readonly properties and a type checker that catches real bugs.

On performance, honestly

PHP is not slow. That belief dates from PHP 5, and PHP 8 with opcache and JIT is a different runtime — several times faster than 5.6 on the same code.

More usefully: for the overwhelming majority of web applications, neither runtime is the bottleneck. A request that makes three database queries spends its time in the database. Swapping the language changes the small part of the budget and leaves the large part alone. Benchmarks that show dramatic differences are usually measuring JSON serialisation in a tight loop, which is not what your application does.

The place the runtime genuinely decides throughput is concurrent connections, and there the models diverge sharply. PHP-FPM dedicates a worker process to a connection for its whole lifetime, so a thousand idle open connections is a thousand processes — which is untenable. Node's event loop handles that number of idle sockets in one process without noticing. For WebSockets, server-sent events, long polling or streaming, this is not a preference, it is the architecture.

Where each one is clearly right

Choose Node when the application is connection-shaped. Real-time collaboration, chat, live dashboards, a streaming API, anything pushing to many clients at once. Also when the team is already writing TypeScript on the front end and sharing types across the boundary is worth real money — that is a genuine advantage and it is not marketing.

Choose PHP when the application is request-shaped. A CRUD application over a database, an admin panel, a content site, a commerce back end. Laravel and Symfony are mature in a way that matters at month eighteen rather than week one: first-party queues, scheduling, auth, migrations, testing, admin panels, and an upgrade path that does not require rewriting.

Two more practical points that decide more cases than the technical ones.

Deployment floor. PHP still runs on hosting that costs a few pounds a month, with no process supervisor and no build step. Node needs a process manager, a restart policy and someone who understands why the memory graph climbs. For a small site maintained by whoever is around, that difference is the whole decision.

The blast radius of an average mistake. In PHP, a junior's memory leak is invisible. In Node, an unhandled promise rejection in a rarely-hit code path takes the server down at 3am. Node's model asks more of the team, permanently.

The supply chain, which nobody includes in these comparisons

Worth weighing because it is an ongoing cost rather than a choice you make once.

npm dependency trees are deep. A typical Node application pulls in hundreds of transitive packages, many maintained by one person, and the ecosystem has a documented history of compromised packages and dependency confusion. Composer trees are usually much flatter, and the framework does more of what you would otherwise install.

This is not an argument that one is unsafe. It is an argument that the audit surface differs by an order of magnitude, and if you are in a regulated environment, someone will eventually ask you to enumerate it.

What the model change actually looks like in code

The hazard worker mode introduces is easy to describe and easy to miss in review. This is fine under PHP-FPM and a cross-request data leak under FrankenPHP or Swoole:

php
class CurrentUser
{
    // Wiped after every request under FPM. Survives forever under a worker.
    private static ?User $user = null;

    public static function set(User $u): void { self::$user = $u; }
    public static function get(): ?User { return self::$user; }
}

Under shared-nothing, that static is a convenience. Under a resident worker, request two sees request one's user until something overwrites it — and if request two is unauthenticated, it may never overwrite it. The same class of bug exists in Node by default, which is why module-level mutable state is a well-known smell there and barely discussed in PHP.

The equivalent Node footgun is the one that takes the process with it:

javascript
// No catch. In Node 15+ this terminates the process by default.
app.get('/report', async (req, res) => {
  const data = await buildReport(req.query)   // throws on bad input
  res.json(data)
})

One unhandled rejection on a rarely-hit path and every in-flight request dies with it. PHP's equivalent mistake returns a 500 for that one request and nothing else notices.

Tooling, at month eighteen

Early velocity is a poor guide, because both get you to a working prototype quickly. The difference shows up later.

PHP's frameworks are opinionated and first-party: queues, scheduled jobs, database migrations, authentication, mail, an admin panel and a test harness all ship from the same vendor on the same release cycle. Upgrading is one coordinated step. Node assembles the same capabilities from independently versioned packages, which gives you more choice and more coordination work — the upgrade that breaks is usually the one where two of those packages disagree.

Static analysis has converged. PHPStan and Psalm at their stricter levels catch a comparable class of bug to TypeScript in strict mode, and PHP 8's type system is genuinely good now — enums, readonly, never, intersection types. Anyone comparing "untyped PHP" against TypeScript is comparing against a version of PHP that competent teams stopped writing years ago.

Deciding, in four questions

  • Does the application hold many connections open at once? Yes → Node. This is the one genuinely architectural answer here.
  • Is it request/response over a database? Then both work, and the decision is the team, not the runtime. Pick what they already know.
  • Who operates it at 3am? If the answer is "whoever is around", PHP-FPM's per-request isolation is worth more than throughput.
  • Are you already writing TypeScript on the front end? Shared types across the stack is a real, compounding benefit and it tilts the balance on its own.

The answer that is almost never right: rewriting a working application from one to the other. The runtime is rarely what is wrong, and a rewrite spends a year reproducing behaviour you already had.

Scope

Version-specific details above reflect PHP 8.x and current Node LTS. Worker-mode PHP (FrankenPHP, Swoole, RoadRunner, Laravel Octane) changes the memory model substantially and the trade-offs described under shared-nothing do not apply once you adopt it. We have not benchmarked either runtime here, and no throughput figures are our own — any comparison quoting requests per second without stating the workload, the database and the concurrency level is describing one test rather than the runtimes.

nodejsphpbackendarchitecturefrankenphptypescriptperformance

Arslan ud Din Shafiq

Founder and lead editor of LearnCybers. Full-stack engineer with expertise in Linux systems, cybersecurity, cloud infrastructure and web development. Writing about practical technology since 2019.

Related reading

Newsletter

Get smarter about security

Practical guides, tooling notes and the developments actually worth your attention — delivered when there is something worth saying.

No spam. Unsubscribe in one click.