Memory leaks in long-running workers: Node vs Go vs PHP often produce surprisingly different outcomes even when the underlying bug is identical. A global cache that never evicts, an event listener attached on every job, a goroutine blocked forever on a channel — any of these can turn a healthy worker into an ever-growing memory hog. But the way each runtime contains, hides, or eventually cleans up that leak is shaped by one architectural decision above all: whether worker state is isolated inside disposable processes or shared inside a single address space. Understanding that tradeoff matters more than memorizing garbage-collector behavior, because it determines how much memory overhead you carry in exchange for leak safety.
Why Long-Running Workers Leak (and What “Safe” Really Means)
A lingering reference keeps garbage from being collected, and in long-running workers that reference usually accumulates incrementally: one stale object per request, one detached closure per job, one unresolved timer per failed retry. The garbage collector can only reclaim memory that is no longer reachable. If your code accidentally stores every job.id in a global map, the collector will faithfully preserve that memory forever.
Leak “safety” is therefore not about detecting leaks earlier. It is about limiting how much damage a leak can do during the lifetime of a process. In practice, that means three mechanisms:
- Reachability analysis: the GC can reclaim unreachable memory, but only if references are actually released.
- Lifecycle boundaries: a worker that is periodically replaced resets accumulated state, including leaks.
- Process isolation: separate processes provide a hard boundary; when the process dies, every leaked byte dies with it.
Node.js, Go, and PHP each use these mechanisms in different proportions, and that is where the tradeoff starts.
Node.js: Flexible Workers but Fuzzy Memory Boundaries
Node.js has no single answer for worker architecture. You can run a single process with an event loop, spawn a cluster of child processes, or use worker_threads for parallel CPU-bound work. This flexibility is valuable, but it also makes leak containment a concurrency-model decision rather than a runtime guarantee.
In the default single-process mode, every request and every background job shares the same V8 heap. A leak in one request handler can slowly poison the entire process. This is why so many Node.js production teams eventually add a watchdog that restarts the service when RSS crosses a threshold. The process is the unit of isolation, and if you never replace the process, the leak accumulates without a built-in reset.
worker_threads gives each thread its own JavaScript heap, which is better for CPU-bound tasks, but the threads still live inside the same OS process. A worker thread can be terminated to release its heap, but diagnosing which worker is leaking requires careful instrumentation. cluster, by contrast, uses real child processes; each child process has its own V8 heap, event loop, and memory footprint. If a cluster worker leaks, you can kill and restart that worker without taking down the parent. The cost is higher memory usage: each child process needs its own runtime, its own heap, and its own copy of loaded modules.
The takeaway for Node.js is that leak safety is opt-in. You must decide which parts of your workload deserve process isolation and build restart logic around them.
Go: Goroutines Share Everything, Including Leaks
Go’s concurrency model is built around goroutines: lightweight, cooperative, and strikingly cheap. You can launch thousands of them in a single process, and the scheduler handles the rest. This makes Go an excellent fit for network servers and batch workers, but it also means the entire worker pool lives in one shared address space. There is no per-goroutine heap, no process boundary, and no way to kill a single stuck goroutine from the outside.
Goroutine leaks are a classic failure mode. A goroutine blocks forever on a channel receive, waits on a context that nobody cancels, or reads from a timer that was never stopped. The goroutine’s stack remains alive, and every object it references stays reachable. Because goroutines are so cheap, these leaks are easy to miss at first. Memory growth may be slow, but eventually the process is pinned at a high resident set size and the OOM killer starts making decisions for you.
Go developers often respond with more runtime instrumentation: runtime/pprof, heap profiles, and custom metrics that expose the number of active goroutines. That helps with visibility, but it does not replace fault isolation. Unlike PHP-FPM, where a misbehaving worker can be recycled, Go has no built-in worker recycle mechanism. The usual production answer is to run multiple instances of the same service behind a load balancer or supervisor, then restart individual instances when metrics indicate a problem. This is process isolation applied at the deployment level rather than the runtime level.
PHP: Process Isolation as a Leak Safety Net
PHP has a different history. In a classic PHP-FPM setup, the unit of concurrency is the worker process, not the thread or goroutine. PHP-FPM keeps a pool of worker processes alive to handle incoming requests. Each request is executed inside a worker, and when the request ends, PHP tears down its variables, objects, and resources. This is why PHP is often described as “nothing persists between requests.” For long-running workers, that is both a limitation and a superpower.
The superpower is leak resistance by default. If a PHP request accidentally accumulates a lot of memory, a single request does not have time to consume a host. The memory is freed when the request finishes. But there is a subtle leak vector inside PHP-FPM: a single worker process handles many requests over its lifetime, and extensions or userland globals can retain data across requests. Over time, a worker’s memory usage can drift upward.
PHP’s built-in answer is pm.max_requests. When a worker has processed a configured number of requests, PHP-FPM kills it and starts a fresh replacement. This is a deliberate, coarse-grained form of leak safety: instead of finding every stale reference, you simply accept that worker processes are disposable. The older the worker, the more accumulated garbage it might contain, so you recycle it before it becomes a problem.
Modern PHP runtimes like Swoole and RoadRunner change the request model by keeping PHP code alive between requests. That gives PHP persistent objects, shared connection pools, and real long-running workers, but it also removes the automatic request-by-request cleanup. Teams moving to these runtimes must reintroduce restart policies, memory limits, and leak detection — in other words, they start to face the same isolation tradeoff as Node and Go.
The Resource-Overhead Equation: Process Isolation Trades Leak Safety for Memory
Process isolation is not free. Every extra worker process means extra memory for its runtime, its loaded libraries, and its private state. This is the fundamental tradeoff: you reduce the blast radius of a memory leak by adding more process boundaries, but each boundary adds fixed overhead.
- Node.js cluster workers isolate crashes and leaks but duplicate the V8 heap and loaded modules for each worker.
- Go instances share one runtime and can run many goroutines with tiny overhead, but a leak in any goroutine can sink the entire process.
- PHP-FPM workers are relatively small and cheap, but each process has its own PHP runtime, opcache state, and database connection pool, which can add up quickly.
In a world where memory is deliberately budgeted per container, this overhead matters. A PHP application that needs 30 FPM workers to sustain traffic will use far more memory than an equivalent Go service with 30 goroutines. The operational advantage is that the PHP workers will not silently grow forever — or at least, they will be recycled before they do. The Go service will be extremely efficient until the moment a leaked goroutine pins a shared dependency, and then the whole process must be restarted.
Choosing an Isolation Strategy for Your Own Workers
No runtime is universally better at preventing memory leaks. The right choice depends on whether you can tolerate periodic process restarts, how quickly you can detect leaks, and how much memory overhead your infrastructure allows.
If you run Node.js, treat worker threads as a containment layer for CPU-bound work, but do not assume they protect you from process-level memory pressure. Use cluster or separate service deployments for code paths with higher leak risk, and build an explicit restart path for workers that exceed a memory threshold.
If you run Go, invest in goroutine and heap profiling from day one. Set alerts on goroutine count and heap growth, and use context cancellation to give every goroutine an exit path. If a specific library cannot be trusted, isolate it in a subprocess or a separate service rather than trying to contain it inside the Go memory space.
If you run PHP-FPM, always set a sensible pm.max_requests. It is the simplest leak-safety mechanism you will ever configure. If you move to Swoole or RoadRunner, add explicit worker restarts and monitor memory usage per worker just as you would in Node or Go.
Conclusion
Memory leaks in long-running workers are unavoidable; what matters is how much damage one leak can cause before someone or something intervenes. PHP’s process isolation turns leaked memory into a renewable resource by periodically recycling workers, while Node.js and Go trade that safety net for lower memory overhead and finer-grained concurrency. Neither approach is wrong, but every stack owner must accept the tradeoff: process isolation costs memory, and shared-memory efficiency costs leak containment. The key is to choose a strategy that bounds the worst case, not one that merely works well on a clean benchmark.
