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:
- Stores the current blog ID on a stack (so
restore_current_blog()knows where to go back) - Calls
wp_cache_switch_to_blog()to point the object cache at the new blog's key prefix - 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
alloptionsget 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.
