I recently chased a WordPress Multisite performance problem that made the admin feel unusable. Network screens like Sites and Plugins were taking about 20 seconds. That was on a healthy server and a database with no slow query alarms. It turned out to be a classic case of doing too much work on every admin request, hidden in a place most people do not look.
The fix was small and the impact was huge. If you run a large multisite, this is worth checking.
The symptoms did not match the server metrics
CPU was fine. PHP-FPM had plenty of idle workers. The database looked stable. Yet the admin was crawling. That ruled out raw capacity and pointed to something in the request path.
I turned on PHP-FPM slowlog so I could see stack traces for slow requests. The PHP manual explains the key directives. slowlog defines the log file and request_slowlog_timeout sets the threshold for a stack trace. I kept the reference handy while I was tuning it. PHP-FPM configuration directives.
With slowlog enabled at 3 seconds, the slow stacks pointed at something surprising.
The admin bar was the bottleneck
WordPress builds the admin bar on every admin page. During initialisation, it calls get_blogs_of_user() to populate the user site list, and it also calls get_active_blog_for_user() for multisite. That is in core, not a plugin. You can see the call path in WP_Admin_Bar::initialize() and the helper function in get_active_blog_for_user().
On a multisite network, get_blogs_of_user() has to determine which sites the current user is a member of. On very large networks, this can be expensive. The function is documented in the code reference for get_blogs_of_user(). Core tickets #31746 and #55911 describe how it can get slow when a user belongs to hundreds or thousands of sites and the admin bar triggers extra site switches.
Based on the initialisation path in WP_Admin_Bar::initialize(), hiding the My Sites menu alone does not help. The admin bar still initialises, and the data is still loaded even if you never show it.
The smallest safe fix at scale
WordPress provides a short circuit for this exact function via the pre_get_blogs_of_user filter. If you return a non null value from that filter, core will use it and skip the default work. The behaviour is documented in pre_get_blogs_of_user.
I used that hook to return only the current site for admin requests. That keeps the admin bar happy without scanning a giant list of sites. It also avoids the extra switch_to_blog() work highlighted in the tickets above.
Here is the pattern I used as a must use plugin. It is intentionally narrow. It only runs in admin, and it skips AJAX, REST, and CLI to avoid breaking background flows.
<?php
/**
* Short-circuit get_blogs_of_user in admin to avoid scanning thousands of sites.
*/
add_filter('pre_get_blogs_of_user', function ($sites, $user_id, $all) {
if (!is_admin()) {
return $sites;
}
if (wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST) || (defined('WP_CLI') && WP_CLI)) {
return $sites;
}
if (empty($user_id)) {
return [];
}
$blog_id = get_current_blog_id();
$site = get_site($blog_id);
$site_obj = (object) [
'userblog_id' => $blog_id,
'blogname' => $site ? $site->blogname : get_option('blogname'),
'domain' => $site ? $site->domain : '',
'path' => $site ? $site->path : '/',
'site_id' => $site ? $site->network_id : get_current_network_id(),
'siteurl' => $site ? $site->siteurl : get_option('siteurl'),
'archived' => $site ? $site->archived : 0,
'mature' => $site ? $site->mature : 0,
'spam' => $site ? $site->spam : 0,
'deleted' => $site ? $site->deleted : 0,
];
return [$blog_id => $site_obj];
}, 10, 3);
On the network I was working on, this took admin screens from around 20 seconds to a couple of seconds. That was a real, immediate improvement with a small patch.
How I validated it
I kept slowlog enabled long enough to confirm that admin requests no longer ended up in the admin bar and multisite stack. The slow traces moved to front end templates and image metadata work. That was a better place to be.
For future work, I also added a tiny admin profiler that logs slow admin requests and slow HTTP calls to a text file. That helps catch things like plugin update checks or external API calls that stall the admin. It is lightweight and easy to toggle with environment variables.
<?php
$start = microtime(true);
add_action('http_api_debug', function ($response, $type, $class, $args, $url) {
if (!is_admin()) {
return;
}
// log slow HTTP calls here
}, 10, 5);
register_shutdown_function(function () use ($start) {
if (!is_admin()) {
return;
}
$elapsed = microtime(true) - $start;
if ($elapsed < 2.0) {
return;
}
// log slow admin request here
});
Trade-offs and safeguards
This fix is not a universal solution. It changes the behaviour of get_blogs_of_user() in admin, so anything that relies on the full site list inside wp-admin will see only the current site. For most installs this is fine, especially if you already hide the My Sites menu. If you do need the full list on specific screens, add a bypass. A query param or a feature flag is usually enough.
The core takeaway is simple. On large multisite networks, the admin bar can be the hidden cost that makes everything slow. WordPress gives you a filter to short circuit the work. Use it carefully, and you can get massive wins without changing servers or touching the database.