The Plugin Audit: How to Cut a Bloated WordPress Install in Half
A practical WordPress plugin audit: profile real per-plugin cost, find autoloaded option bloat, remove plugins cleanly with WP-CLI, and stop the sprawl
A client handed us a WooCommerce site last spring with 68 active plugins and a homepage TTFB of 1.9 seconds on a decent managed host. The hosting company had already told them to upgrade the plan. We cut it to 31 plugins over three weeks, changed nothing about the hosting, and TTFB landed at 480ms. Admin page loads went from painful to boring.
Here’s the part the listicles get wrong: we didn’t save that time by removing 37 plugins evenly. Four of them accounted for roughly 70 percent of the recovered time. The other 33 were removed because every one of them was an update to apply, a vulnerability to track, and a row in wp_options nobody owned.
A proper WordPress plugin audit is not a deactivation spree. It’s an inventory, a measurement pass, and a removal procedure, in that order. The removal is the part people botch.
- Plugin count is a weak predictor of performance. One badly written plugin firing an uncached HTTP request on
initcosts more than twenty well behaved ones. Measure per plugin cost before you decide anything. - Profile with Code Profiler or Query Monitor on a staging clone with production data. Synthetic test content hides the plugins that scale badly with 40,000 posts.
- Deactivating is not removing. Check for orphaned database tables, autoloaded options, scheduled cron events and leftover shortcodes before you call a plugin gone.
- Autoloaded options above roughly 800KB is the threshold where we start seeing measurable slowdown on every single request, including admin-ajax and REST calls.
- The durable fix is governance: a written rule for what earns a plugin slot, and a 20 line mu-plugin instead of a 2MB plugin for single-purpose tweaks.
Plugin count is a symptom, not the disease
Someone on your team will say the site has too many plugins. They’re probably right, but not for the reason they think. We’ve audited 12 plugin installs that were slower than 50 plugin installs. Count correlates with trouble because sprawling installs tend to accumulate the specific kinds of plugins that are expensive: ones that hook wp_loaded and do remote calls, ones that register dozens of post types and meta boxes, ones that load their entire CSS and JS bundle on every page because the author never bothered with conditional enqueuing.
What actually costs you:
- Uncached third-party HTTP requests during page generation. A licence check or font fetch on every front-end load. We’ve seen 600ms of pure network wait from a single plugin phoning home.
- Autoloaded options. Every request unserialises the entire autoload set. Plugins that store logs or cached API responses in options are a genuine disaster at scale.
- Queries that don’t scale. Fine on 200 posts, catastrophic at 40,000. Related posts plugins are the classic offender.
- Asset weight. Not server time, but real user time. Six plugins each loading their own jQuery plugin and icon font adds up fast. We wrote about the icon side of this in SVG sprites versus icon fonts.
- Security and maintenance surface. Every plugin is a dependency you now own. This cost doesn’t show up in any profiler.
That last one justifies removing plugins that cost zero milliseconds. A plugin that does nothing measurable but hasn’t been updated in three years is still a liability.

Build the inventory first
Before any profiling, get the facts in a spreadsheet. WP-CLI makes this two minutes of work:
# Full plugin inventory with update status
wp plugin list --fields=name,title,status,version,update,auto_update --format=csv > plugins.csv
Which plugins have pending updates or are no longer in the directory
wp plugin list --update=available --format=table
Scheduled cron events, grouped by hook, shows who is doing background work
wp cron event list --fields=hook,nextrunrelative,recurrence --format=table
Add four columns by hand: who asked for it, what breaks without it, last upstream release date, and replaceable by. The “who asked for it” column is the one that does the work. On that 68 plugin site, 14 entries had no owner at all. Three were installed to test something in 2021 and never removed.
Then look at the options table, because this is where the quiet damage lives:
# Note: since WordPress 6.6 the autoload column uses 'on'/'off'/'auto-on'/'auto-off'
alongside the legacy 'yes'/'no', so match all the truthy variants.
wp db query "SELECT optionname, ROUND(LENGTH(optionvalue)/1024, 1) AS kb
FROM wp_options
WHERE autoload IN ('yes','on','auto','auto-on')
ORDER BY LENGTH(option_value) DESC
LIMIT 25;"
Total autoloaded payload in KB
wp db query "SELECT ROUND(SUM(LENGTH(optionvalue))/1024, 1) AS totalkb
FROM wp_options WHERE autoload IN ('yes','on','auto','auto-on');"
Under 300KB is healthy. Over 800KB and you’ll feel it everywhere. We found a 4.2MB autoload set once: a slider plugin was caching rendered HTML into an option, and nothing ever pruned it.
Measure cost per plugin, properly
Clone production to staging with real data. Not a fresh install with three posts. The whole point is to catch the plugins whose queries degrade with content volume.
Three tools, in order of usefulness:
Code Profiler
It instruments plugin-by-plugin execution time and memory without needing Xdebug or a PHP extension. Run it against the homepage, a product archive, a single post, and wp-admin/post.php for a long post. That last one matters: editor-side plugins can make your client’s day miserable while the front end looks fine.
Query Monitor
For attribution. Query Monitor tells you which plugin fired which query and which HTTP request, with timings. Sort the HTTP API panel by duration. Anything over 100ms on a front-end request is a bug, regardless of what the plugin does.
The Health Check troubleshooting mode
The official Health Check & Troubleshooting plugin disables plugins for your session only, so you can A/B a plugin’s absence on a live site without taking it down for visitors. Useful for “does the site still render” checks, useless for timing, because your session is uncached by definition.
Record a before number you trust. We use three runs of curl -w "%{time_starttransfer}" against a cache-busted URL, take the median, and note PHP version and object cache state in the same row of the sheet. Without that discipline you’ll end up arguing with a client about whether anything improved.
Every bloated install sorts into five buckets
Once the inventory is populated, sort each plugin into one of these. Decisions become obvious.
- Load-bearing. WooCommerce, the membership system, the booking engine. Keep. Optimise their configuration instead.
- Overlapping. Two caching plugins, three SEO plugins, an image optimiser plus a CDN plugin that also optimises images. Pick one and migrate. Overlap is where you get bugs that take hours to trace.
- One-function plugins. “Disable comments”, “remove the admin bar”, “add a script to the header”, “duplicate post”. Most are 5 to 30 lines of PHP you can move into a single mu-plugin.
- Theme-replaceable. Slider, mega menu, portfolio grid, pricing tables, lightbox, countdown. If your theme ships these, the plugins are dead weight. This is the honest argument for a fully featured theme: CanvasWP covers most of that list natively, and the plugins it replaces are precisely the ones that load assets sitewide.
- Abandoned or unowned. No update in 18 months, no owner in the sheet, nothing visibly breaks. Remove, after a backup.
Page builders deserve their own mention. If the audit keeps pointing back at builder addon packs, the plugin count is the wrong problem to solve first. We covered the actual migration path in moving a client site off a page builder to native blocks.
Removing a plugin without leaving a mess
Here’s what people do: deactivate, delete, move on. Here’s what’s left behind on about a third of plugins we’ve removed.
Custom tables. Plugins only drop their tables if the author wrote an uninstall.php and you deleted through the admin UI rather than FTP. Check:
wp db query "SHOW TABLE STATUS" --skip-column-names | awk '{print $1, $7}'
Autoloaded options. Search by prefix before you delete. Always take a database dump first.
wp db export pre-audit-$(date +%F).sql
wp option list --search="wpforms*" --fields=optionname,autoload --format=table
Only after you have confirmed the plugin is gone for good:
wp option delete wpforms_version
Orphaned cron events. A deleted plugin’s hook stays scheduled, and WP-Cron will try to fire it on every page load until it’s cleared. wp cron event delete <hook>.
Shortcodes in content. This is the one that burns you three weeks later, when a 2019 blog post renders [su_box] as literal text. Grep before removing:
wp db query "SELECT ID, posttitle FROM wpposts
WHERE postcontent LIKE '%[su%' AND post_status = 'publish';"
Then fix the content, or register a no-op shortcode so nothing leaks to visitors while you work through it:
<?php
// mu-plugins/legacy-shortcode-fallbacks.php
// Swallow shortcodes from removed plugins so raw tags never reach the page.
add_action( 'init', function () {
foreach ( [ 'subox', 'subutton', 'vc_row' ] as $tag ) {
if ( ! shortcode_exists( $tag ) ) {
add_shortcode( $tag, function ( $atts, $content = null ) {
return do_shortcode( (string) $content ); // keep inner content, drop the wrapper
} );
}
}
} );
Remove one plugin at a time on staging, with a smoke test between each. Batching feels efficient until something breaks and you’ve got nine suspects.
What to use instead
The goal is not zero plugins. It’s fewer dependencies doing more per unit of risk.
An mu-plugin for the tweaks. One file in wp-content/mu-plugins/, always loaded, can’t be accidentally deactivated by a client, no update notifications. Everything from the “one-function” bucket goes here. Keep it readable and comment every block with why it exists:
<?php
/**
- Plugin Name: Site Tweaks
- Description: Replaces 6 single-purpose plugins. Owner: dev team.
*/
// Was: "Disable Comments" plugin. Site has no comments anywhere.
addfilter( 'commentsopen', '__return_false', 20 );
addfilter( 'pingsopen', '__return_false', 20 );
// Was: "Disable XML-RPC". Blocks the pingback amplification vector.
addfilter( 'xmlrpcenabled', '__return_false' );
// Was: a 1.4MB plugin that only dequeued the block library CSS on non-block pages.
addaction( 'wpenqueue_scripts', function () {
if ( ! has_blocks() ) {
wpdequeuestyle( 'wp-block-library' );
}
}, 20 );
External services for anything that doesn’t need to live in WordPress. Form plugins are the usual candidate. Most sites need a contact form, not a form builder with entry storage, conditional logic and a Stripe addon. Posting to an endpoint like WebForms removes the plugin, the database table and the GDPR question about storing submissions in your WordPress install. If you need the submissions in more than one place, the routing patterns we use for Slack, email and CRM apply directly.
Theme-level features for presentation. A genuine caveat here: don’t replace three plugins with one monolithic plugin that happens to bundle the same features. You’ve traded three small dependencies for one large one with a bigger attack surface. The win only exists if the replacement is something you were already loading.
Some plugins look replaceable and aren’t. Translation is one. Rolling your own multilingual layer to avoid a plugin is a bad trade on almost every project we’ve seen.
Keeping the count down after the audit
Six months after an audit, most sites drift back up. The fix is a rule, written down, in the project README:
- A new plugin needs a named owner and a one-line justification in the plugin register.
- Anything under about 50 lines of logic goes into the site tweaks mu-plugin instead.
- Check the plugin’s last update date and open support threads before installing. If the changelog stops in 2023, find another option.
- Re-run the autoload query and
wp plugin list --update=availableas part of quarterly maintenance. It’s a 10 minute job. - Removals happen in the same sprint they’re decided. “We’ll clean it up later” is how you get to 68.
One trade-off worth stating plainly: consolidating into mu-plugins means your client can’t turn things off from the dashboard. On a self-managed site that’s a feature. On a site you hand over and never touch again, it’s a trap, because the next developer won’t know those files exist. Leave a comment block at the top of every mu-plugin naming the plugins it replaced.
Frequently Asked Questions
How many plugins is too many for WordPress?
There’s no number. We run sites with 45 plugins that respond in under 300ms and we’ve fixed sites with 11 plugins that took 2 seconds. The useful questions are whether each plugin is maintained, whether it loads assets on pages that don’t need them, and what it adds to your autoloaded options. Treat a high count as a prompt to audit, not as the finding itself.
Does deactivating a plugin improve performance as much as deleting it?
For front-end speed, almost always yes: deactivated plugins don’t load any code. For security it makes little difference, because the files are still on disk and some vulnerabilities are exploitable in inactive plugins. Delete rather than deactivate, and keep a backup of the ZIP if you think you might need it back.
What’s the safest order to reduce WordPress plugins on a live client site?
Clone to staging with production data, remove one plugin, run a smoke test on the templates that plugin touched, then repeat. Push to production in small batches of two or three removals with a database backup before each push. Keep the old plugin ZIPs for 30 days and don’t do any of it on a Friday.
How do I find out which specific plugin is slowing down my site?
Install Query Monitor and Code Profiler on staging and load the actual slow page. Query Monitor attributes queries and outbound HTTP calls to the plugin that triggered them, which is usually enough to spot the offender immediately. If the slowdown only appears under traffic, you need server-level tracing such as New Relic or Blackfire rather than a plugin-based profiler.
Should I replace plugins with code snippets in functions.php?
Use a must-use plugin instead of functions.php. Code in the theme’s functions file disappears the moment someone switches theme or updates a non-child theme, and it mixes site behaviour with presentation. A single file in wp-content/mu-plugins/ survives theme changes, loads before everything else, and can’t be deactivated by accident.
Where to start tomorrow
Pick your worst site. Run the three WP-CLI commands in the inventory section, paste the results into a sheet, and fill in the owner column. That’s 30 minutes. In our experience it identifies between a quarter and a third of the install as removable before you profile anything.
Then profile, remove the two or three genuinely expensive plugins first for the visible win, and work through the long tail of unowned ones over the following weeks. Measure the same way every time, keep the before and after numbers, and write the governance rule down before anyone installs the next thing.


