WordPress Multisite Batch Operations Crashed PHP-FPM — The switch_to_blog() Memory Leak

· 12 min read

A client running a WordPress Multisite network — 38 subsites, mostly WooCommerce stores for different regional brands — asked me to write a maintenance script. The brief was simple: loop through every subsite once a day, delete expired transients, clean up Action Scheduler logs older than 30 days, and regenerate product lookup tables where needed.

I wrote it as a wp-cli custom command. It worked perfectly on the staging network of 5 subsites. On production with all 38, it ran for about 90 seconds and then the PHP-FPM worker silently died. No error in the WordPress debug log. No PHP fatal error. Just a dead process.

The Symptom

The custom WP-CLI command would process roughly 20–25 subsites before the process vanished. No output, no error message, just back to the shell prompt with a non-zero exit code. Running it again would get through another 20-odd sites (different ones, since it picked up where the previous run's changes had already landed) before dying again.

I checked dmesg for the tell-tale sign:

dmesg | tail -20
[423156.891] Out of memory: Killed process 29847 (php) total-vm:1286432kB, anon-rss:524288kB

The OOM killer was executing my WP-CLI process. It was consuming over 512MB of RSS memory — on a server where the CLI memory limit was set to 512M in wp-config.php.

The Investigation

My first instinct was that the Action Scheduler cleanup was loading too many rows. I added memory_get_usage() logging at the start and end of each subsite iteration:

foreach ( $blog_ids as $blog_id ) {
    $before = memory_get_usage( true );
    switch_to_blog( $blog_id );

    // ... maintenance tasks ...

    restore_current_blog();
    $after = memory_get_usage( true );

    WP_CLI::log( "Site {$blog_id}: {$before}{$after} (" . ( $after - $before ) . " delta)" );
}

The output told the whole story:

Site 2: 42991616 → 48234496 (5242880 delta)
Site 3: 48234496 → 53477376 (5242880 delta)
Site 4: 53477376 → 58720256 (5242880 delta)
...
Site 24: 157286400 → 162529280 (5242880 delta)
Site 25: 162529280 → 167772160 (5242880 delta)

Every single iteration added exactly 5MB. The memory never went down. After 38 sites, the process had accumulated over 230MB on top of its starting footprint — enough to push past the 512MB ceiling.

I commented out the maintenance tasks entirely, leaving only the switch_to_blog() / restore_current_blog() pair. The leak persisted. The tasks weren't the problem. The blog switching itself was.

The Root Cause

WordPress's switch_to_blog() does three things when you call it:

  1. Stores the current blog ID on a stack (so restore_current_blog() knows where to go back)
  2. Calls wp_cache_switch_to_blog() to point the object cache at the new blog's key prefix
  3. Reinitialises globals — $wpdb->prefix, taxonomy registrations, rewrite rules, and loaded textdomains

The problem is in what it does not do. switch_to_blog() does not flush the object cache entries from the previous blog. It changes the prefix so new lookups hit the correct cache group, but all the data loaded for blog 2 is still sitting in memory when you switch to blog 3. And blog 3's data sits in memory when you switch to blog 4. Every switch accumulates.

restore_current_blog() is equally guilty. It switches the prefix back but doesn't clean up either. WordPress core Trac ticket #14992 has tracked this behaviour since 2010 — it's a known architectural limitation, not a bug anyone forgot to fix. The object cache was designed for single-request lifetimes where a web request touches one blog. Batch operations that iterate across dozens of blogs in a single PHP process are not the expected use case.

On top of the object cache accumulation, each switch triggers:

  • Options loading: Every blog's alloptions get loaded into memory and stay there
  • Taxonomy re-registration: Custom taxonomies from each blog's active plugins accumulate in $wp_taxonomies
  • Textdomain loading: Translation files for each blog's locale get loaded and cached in memory
  • Hook accumulation: If any blog's plugins register hooks during switch_to_blog(), those hooks persist in $wp_filter

With 38 subsites, each running WooCommerce with a handful of extensions, the accumulated data adds up fast.

The Fix

The solution is process isolation. Instead of iterating through all subsites in a single PHP process, each subsite gets its own short-lived process that starts clean and dies clean.

Option 1: WP-CLI with xargs (simplest)

Instead of a single command that loops internally, pipe the site list through xargs so each site runs as a separate WP-CLI invocation:

wp site list --field=url --network=1 | xargs -n 1 -I {} wp --url={} transient delete --expired

Each wp --url={} invocation spawns a new PHP process. It loads WordPress once for that specific subsite, does the work, and exits. No switch_to_blog() involved at all. Memory usage stays flat.

For my maintenance routine, I broke it into separate passes:

#!/bin/bash
SITES=$(wp site list --field=url --network=1)

echo "$SITES" | xargs -n 1 -I {} wp --url={} transient delete --expired

echo "$SITES" | xargs -n 1 -I {} wp --url={} action-scheduler run --batch-size=100 --force

echo "$SITES" | xargs -n 1 -I {} wp --url={} wc update_lookup_tables

Option 2: Chunked loop with manual cache flush

If you must use switch_to_blog() in custom code — say you're collecting data across all sites into a single report — flush the object cache explicitly after each iteration:

foreach ( $blog_ids as $blog_id ) {
    switch_to_blog( $blog_id );

    // Do your work here.
    $data[ $blog_id ] = get_option( 'woocommerce_store_city' );

    restore_current_blog();

    // Force the object cache to drop everything.
    wp_cache_flush();
}

wp_cache_flush() tells the cache backend (whether it's the built-in array cache or Redis/Memcached) to drop all stored entries. This prevents the accumulation. The tradeoff is that the next iteration has to re-fetch data that was already cached — on a network with Redis, that means extra Redis round-trips, but it's far cheaper than OOM-killing your process.

Option 3: Process forking for parallel operations

For large networks (100+ subsites) where serial execution is too slow, you can fork child processes from PHP:

$blog_ids = get_sites( [ 'fields' => 'ids', 'number' => 0 ] );
$concurrency = 4;
$running     = [];

foreach ( $blog_ids as $blog_id ) {
    while ( count( $running ) >= $concurrency ) {
        $pid = pcntl_wait( $status );
        unset( $running[ $pid ] );
    }

    $pid = pcntl_fork();

    if ( $pid === 0 ) {
        // Child process — clean memory space.
        switch_to_blog( $blog_id );
        run_maintenance_tasks( $blog_id );
        restore_current_blog();
        exit( 0 );
    }

    $running[ $pid ] = $blog_id;
}

// Wait for remaining children.
while ( count( $running ) > 0 ) {
    $pid = pcntl_wait( $status );
    unset( $running[ $pid ] );
}

Each forked child inherits the parent's memory but operates independently. When it exits, all its accumulated memory is freed by the OS. This gives you both process isolation and parallelism — four subsites processing concurrently, each in a clean memory space.

Note: pcntl_fork() requires the pcntl extension, which is available in CLI but disabled in most PHP-FPM configurations. This approach only works for WP-CLI commands and cron scripts, not web requests.

Prevention

After fixing the immediate issue, I added monitoring to catch memory creep early on any Multisite network I manage.

Set pm.max_requests in PHP-FPM

Even if your batch scripts are fixed, web requests can still trigger switch_to_blog() via plugins like WPML, MultilingualPress, or any plugin that reads data from the main site. Set a worker recycling limit to prevent long-lived workers from accumulating:

; /etc/php/8.3/fpm/pool.d/www.conf
pm.max_requests = 500

This tells PHP-FPM to kill and respawn each worker after 500 requests. The brief respawn cost is negligible compared to a worker that slowly grows to 200MB.

Add memory monitoring to batch scripts

For any WP-CLI command that runs on a schedule, log peak memory usage:

#!/bin/bash
SITES=$(wp site list --field=url --network=1)
SITE_COUNT=$(echo "$SITES" | wc -l)

echo "[$(date)] Starting multisite maintenance across ${SITE_COUNT} sites"

echo "$SITES" | xargs -n 1 -I {} sh -c '
    wp --url={} transient delete --expired 2>&1
    echo "[$(date)] Processed {} — peak memory: $(wp --url={} eval "echo memory_get_peak_usage(true);" 2>/dev/null)"
'

echo "[$(date)] Maintenance complete"

Size your memory limits correctly

For Multisite CLI operations, set a generous WP_MAX_MEMORY_LIMIT in wp-config.php but also set WP_MEMORY_LIMIT for front-end requests to something sensible:

define( 'WP_MEMORY_LIMIT', '256M' );
define( 'WP_MAX_MEMORY_LIMIT', '512M' );

The admin and CLI memory limit (WP_MAX_MEMORY_LIMIT) should be at least double the front-end limit on Multisite installations. But don't use this as an excuse to avoid fixing the root cause — a leaking script with 1GB of headroom still leaks, it just takes longer to crash.

The Broader Pattern

Every WordPress Multisite network I maintain gets this treatment now. Any script that touches more than one subsite runs through xargs with per-site WP-CLI invocations. The performance difference is negligible — each WP-CLI invocation adds roughly 200ms of bootstrap overhead, so 40 sites adds about 8 seconds to the total runtime. That's nothing compared to the cost of a crashed maintenance job that leaves half your subsites with stale transient data and bloated Action Scheduler tables.

If you're managing a Multisite network and running batch operations, check your scripts for switch_to_blog() loops. Add memory_get_usage() logging. If the line goes up and never comes down, you've got the same leak — and the fix is simpler than you think.

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans | Get in Touch

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs