Why PHP Workers Never Release Memory: The Hidden Block Allocation Pattern

When moving from PHP‑FPM to long‑running processes like RoadRunner or Laravel queue workers, memory usage climbs and never drops, a pattern caused by PHP’s Zend memory manager block allocation, reference counting, and GC behavior, which can be mitigated by redesigning code and worker strategies.

Open Source Tech Hub
Open Source Tech Hub
Open Source Tech Hub
Why PHP Workers Never Release Memory: The Hidden Block Allocation Pattern

Pattern You’ll Keep Seeing

If you switch from PHP‑FPM to long‑running processes such as RoadRunner, Laravel or Symfony Messenger workers, you will encounter a confusing pattern: memory only goes up and never comes down.

Memory only rises. Never falls.

A worker may start at ~40 MB, process a heavy task and jump to 200 MB, then another batch to 350 MB, and stay at 350 MB forever until the process is restarted.

Typical causes like memory leaks, gc_collect_cycles() or adding unset() calls do not change the situation.

In production we saw an unoptimized ORM query load ~100 000 rows, pushing memory from ~40 MB to 360 MB, after which it never decreased.

Why This Pattern Exists: PHP’s DNA

PHP was designed for short‑lived request‑response cycles: a request arrives, PHP processes it, sends a response, then the process dies, releasing all memory automatically. The Zend Memory Manager (ZMM) is optimized for:

Fast allocation during request handling

Zero cleanup cost when the process terminates

Predictable, millisecond‑scale lifetimes

Modern PHP workloads (RoadRunner workers, Laravel queues, custom daemons) keep processes alive for thousands of tasks, exposing the memory model.

How the Zend Memory Manager Actually Works

PHP does not call malloc() and free() directly. Instead it uses ZMM, which allocates large blocks (typically 2–4 MB) and carves out space for strings, arrays, and objects.

Block System

Think of the OS memory as a warehouse renting whole floors, PHP blocks as the floors you rent, and your variables as the furniture on those floors.

One‑Way Memory Flow

$data = fetchHugeDataset(); // allocates 300 MB in a block
processData($data);
unset($data); // PHP marks it free internally
// OS still sees 300 MB allocated

When unset() is called, PHP marks the memory as free in its internal pool, but the OS never reclaims the block; it stays allocated to the process.

PHP marks the memory as free internally.

The memory returns to PHP’s free pool.

The OS never takes the block back.

The block remains assigned to the process.

This is intentional: reusing already‑allocated blocks is far faster than repeatedly requesting and releasing memory from the OS.

Why Blocks Stay Forever

Performance – OS allocation is expensive.

Fragmentation – mixed allocations prevent a block from becoming completely empty.

No shrink mechanism – PHP does not automatically return blocks.

Assumption – PHP assumes you will need the block again soon.

For traditional PHP‑FPM this is irrelevant because the process dies after each request; for long‑running processes it becomes a reality.

Staircase Pattern

Memory │
380MB ┤─────────────────
      │
      │   (peak operation)
      │   ╱
 60MB ┤─────╯
      │
      └─────────────────────► Time

Each peak becomes the new baseline, and the worker’s memory never goes down.

Identifying the Pattern in Your Code

Bulk ORM Operations

// This will peak memory and never release it
$users = User::all(); // loads 50K users
foreach ($users as $user) {
    processUser($user);
}

Large File Handling

// Loads the whole file into memory
$content = file_get_contents('huge-file.csv');
$lines = explode("
", $content);

Growing Result Arrays

$results = [];
foreach ($items as $item) {
    $results[] = expensiveOperation($item);
}

Nested Hydration

$orders = Order::with('customer.address.country')
    ->with('items.product.category')
    ->get();

Each of these patterns creates a permanent memory peak that accumulates after a few iterations.

Designing Around the Pattern

Think Streams, Not Collections

// Keep memory stable – no peaks
Record::where('status', 'pending')
    ->chunk(100, function ($records) {
        foreach ($records as $record) {
            processRecord($record);
        }
    });

Laravel’s lazy collections in PHP 8+ work perfectly:

User::lazy()->each(function ($user) {
    processUser($user);
});

Doctrine iterators also help:

$query = $em->createQuery('SELECT u FROM User u');
foreach ($query->toIterable() as $user) {
    process($user);
    $em->detach($user); // crucial
}

Scope Isolation

class JobHandler {
    public function handle($job) {
        $this->processLargeDataset($job);
        // all local variables are released here
    }

    private function processLargeDataset($job) {
        $data = fetchData(); // ~100 MB peak
        transform($data);
        save($data);
        // peak ends when function exits
    }
}

Embrace Worker Rotation

Configure your worker pools to restart after a certain number of jobs:

# .rr.yaml
pool:
  max_jobs: 1000 # refresh worker every 1K tasks
php artisan queue:work --max-jobs=1000
php bin/console messenger:consume --limit=1000

Rotation is not a workaround; it is a pattern‑aware solution.

Prevent Real Leaks

Pattern Violation: Growing Static State

class Cache {
    private static array $data = [];
    public static function store($key, $value) {
        self::$data[$key] = $value; // forever accumulates
    }
}

Pattern‑Aware Bounded Cache

class BoundedCache {
    private array $data = [];
    private int $maxSize = 1000;
    public function store($key, $value) {
        if (count($this->data) >= $this->maxSize) {
            array_shift($this->data); // prevent unbounded growth
        }
        $this->data[$key] = $value;
    }
}

Practical Takeaways

Accept the pattern – memory peaks are permanent until a restart.

Design for the peak, not the average; a worker will always consume its highest peak.

Keep peaks small: batch operations, stream data, isolate scopes.

Rotate workers; it is a solution, not a hack.

Monitor actively to catch unexpected peaks before they accumulate.

Avoid real leaks by eliminating unbounded static/global state.

Developers who fight the pattern struggle; those who embrace it redesign their long‑running PHP applications for predictable, low‑memory usage.

memory managementgarbage collectionPHPworkerZendlong-running processes
Open Source Tech Hub
Written by

Open Source Tech Hub

Sharing cutting-edge internet technologies and practical AI resources.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.