How we cut WordPress bootstrap cost by loading fewer plugins on specific WooCommerce.com requests
WordPress has a simple plugin model. A site has a set of active plugins, and WordPress loads all of them on every request. For most sites, that is the right tradeoff. Behavior stays predictable, plugins compose freely, and nobody has to reason about which plugin a particular request actually needs.
On a site the size of WooCommerce.com, that simplicity isn’t free.
WooCommerce.com is a large WordPress and WooCommerce application: marketplace pages, account flows, APIs, checkout, partner workflows, search integrations, tracking, and a long tail of operational code.
Most of those plugins are essential somewhere. Only a few are essential everywhere.
That gap is widest on high-traffic pages and narrow API endpoints. A product discovery page doesn’t need marketplace submission tooling. A cached helper endpoint doesn’t need the same plugin graph as checkout. A public docs page doesn’t need order-numbering logic.
The optimization we use is straightforward: for selected routes, we load only the plugins that route needs.

A blog post on WooCommerce.com, for example, usually doesn’t need the payment gateways or tax calculation plugins. On an uncached request those unrelated plugins can account for 10–20% of the time spent generating the page. So there’s a clear case for not loading them at all.
It relies on WordPress option filtering, an early mu-plugin, and route-specific allowlists. The hard part isn’t writing the hook, it’s staying confident the route still works when most of the site isn’t loaded.
This post covers the pattern and the safety rails we added around it.
Plugin bootstrap adds up on large WooCommerce applications
On a typical WordPress request, active plugins load early in bootstrap. Each one can register hooks, instantiate services, read options, load translations, define custom post types, attach REST routes, queue assets for later in the request, or run compatibility logic.
Any one of those costs is fine on its own. Stacked together on a mature WooCommerce application, they add up.
The thing worth noticing is that the active plugin list is global, but request needs are local. A request to /wp-json/wccom-extensions/1.0/search and a request to /checkout/ start from the same active plugin set, even though the code paths that matter are nothing alike.
Full-page and edge caching reduce how often this bites for anonymous page views, but they don’t remove it. Cache misses still happen. API endpoints can be dynamic. Logged-in requests bypass cache. Operational endpoints often need low latency exactly when traffic spikes.
For a large WordPress application, cutting bootstrap work is a real performance lever.
Existing tools solve a different version of this problem
Selective plugin loading isn’t new. Public WordPress plugins already let a site owner disable plugins per page, post type, URL, or context, including Freesoul Deactivate Plugins and Plugin Load Filter.
These are useful, especially when you want a UI for deciding which plugins run where. Some of them ship mu-plugin components too, because selective loading has to happen before normal plugins load.
Our needs were different. We didn’t want a general-purpose UI. We wanted route-owned, code-reviewed rules that live alongside the rest of our platform code, run in CI, and ship through the same controls as everything else.
In our implementation, rules are PHP classes, registration happens in a mu-plugin, and changes go through the same review and production monitoring as any other performance-sensitive code.
Intercepting the plugin list at bootstrap
WordPress keeps the list of active plugins in the active_plugins option. During bootstrap it reads that option and loads each plugin file in the list.
Because options are filterable, an early mu-plugin can rewrite that list before WordPress loads anything:
add_filter(
'option_active_plugins',
function ( array $plugins ): array {
if ( ! should_limit_plugins_for_this_request() ) {
return $plugins;
}
return array_values(
array_diff(
$plugins,
plugins_to_skip_for_this_request()
)
);
}
);
That filter is the core of the pattern. Everything else exists to answer three questions:
- Which requests should be limited?
- Which plugins are safe to skip for each one?
- What stops a limited request from corrupting global state?
On WooCommerce.com a mu-plugin registers route rules early. We match the request URI against exact paths, prefixes, or regular expressions. When a rule matches, it decides which plugins drop out of the active list.
For our internal loader we have a second filter that limits which WooCommerce.com application modules load. That part is specific to our architecture, but the underlying WordPress mechanism is the same.
Route rules decide which plugins can be skipped
Selective loading is expressed as explicit per-route rules, and we keep those rules in code rather than in a settings screen. Each rule covers a route, or a group of routes, and lists the plugins to leave out for that request.
There are two ways you could decide what to leave out.
An allowlist starts from nothing and adds back only the plugins the route needs. That sounds like the clean approach, but on a large site it’s brittle, because implicit dependencies are everywhere. A theme function leans on a class defined by some plugin. A REST handler calls a helper that eventually reaches into another plugin. A cache miss runs code a cache hit never touched. Miss one of those and the route breaks in a way that’s easy to overlook.
An exclusion list works the other way around: start from the site’s normal active plugins and remove the ones you know the route doesn’t need. It’s less theoretically minimal, but far safer. The route keeps everything that might be needed to render the page and only sheds the large, obvious groups such as admin-only tooling, payment gateways, email processing, anything clearly unrelated to the request. That’s the side we lean on most.
Keeping the rules in code rather than a UI helps in review and operations:
- Each rule reads as a diff in review.
- A comment can explain why a plugin is kept or dropped.
- Tests can assert the URL matching and the exclusions that matter.
- A production incident traces back to a specific rule change.
It also enforces a habit I like: every plugin left in a trimmed route has to earn its place.
Requests too risky to trim
Not every request is a good candidate.
We skip exclusion for broad WooCommerce request types with many dynamic paths and side effects:
wc-ajaxwc-apirest_routequery-parameter requests- download requests
That doesn’t mean REST endpoints are off-limits — some pretty-permalink REST routes are targeted explicitly by rules. The line is about predictability. Broad query-parameter entry points can branch into all sorts of behaviour, so they’re poor defaults for exclusion.
Checkout, cart, account, admin, cron, webhooks, payment callbacks, and downloads get extra caution. These touch money, customer data, authentication, entitlement checks, emails, or third-party integrations. The performance upside has to be weighed against the blast radius.
Dependency discovery is the hard part
Working out what a route actually needs is the hard part.
On a dynamic WordPress application there’s no clean static answer. In practice, we read the route code, follow the obvious dependencies, exercise the common request flows by hand, add focused tests for URL matching and endpoint behaviour, roll out incrementally, and watch error logs and performance data after each rollout.
The biggest wins came from focused, high-traffic routes
The goal isn’t the shortest possible plugin list. It’s a faster request that stays reliable.
We started with the highest-traffic surfaces, since trimming bootstrap work off a single request only pays off when that request runs a lot. From there the obvious targets were the endpoints almost every WooCommerce installation calls back to WooCommerce.com, along with high-traffic pages like product pages.
Loading only the required plugins cut memory use on those requests by more than 50%, and brought latency down with it. The call that tells a connected store which paid subscriptions it owns dropped from around 800 ms to 475 ms. The one that reports which of a store’s installed plugins have updates available dropped from 880 ms to 550 ms. The endpoint that runs every time a store is shown the WooCommerce onboarding flow improved by a similar margin.
The same approach extends to customer-facing pages. Most plugins aren’t needed to render a blog page or a product page, which makes them good candidates. An early pass at product pages, removing payment gateways and a few unrelated extensions the page never touches, gave roughly a 10% improvement in page generation time.
Across all of these, the signals worth watching are response time, PHP memory use, database queries, expensive plugin bootstraps, error rates after deploy, unexpected rewrite-rule changes, and route-specific fatals on cache misses.
The biggest wins show up on focused endpoints and high-traffic anonymous pages with large plugin graphs. The weakest candidates are broad, stateful flows where almost anything could turn out to be relevant.
This pattern fits large, engineering-owned WordPress applications
Selective plugin loading is worth a look when:
- the site runs many active plugins
- traffic is high enough that bootstrap cost matters
- specific routes have narrow responsibilities
- engineering owns deployment and monitoring
- the team can write and maintain tests
- the wins can be measured
- a bad change can be rolled back quickly
It’s usually a poor fit when:
- the site is small
- the active plugin graph is already lean
- routes are highly dynamic and stateful
- there’s no staging or production monitoring
- non-technical users are expected to maintain dependency rules
- the team can’t live with occasional dependency-discovery work
For most WordPress sites this isn’t a first optimization. Caching, query performance, asset loading, object cache behaviour, and the obvious plugin bloat should come first.
The hook is simple; keeping routes safe is the hard part
The main thing we learned is that selective plugin loading is a sharp tool, useful, and easy to cut yourself on.
The WordPress hook makes changing the active plugin list for a request trivial. That ease is a little misleading. A missing plugin might only fail on one product, one locale, one request parameter, one cache miss, or one logged-in state. A bad rewrite flush can break routes that have nothing to do with the request that caused it.
So the implementation should stay deliberately conservative. What’s worked best for us:
- Prefer excluding the obviously irrelevant over chasing a perfect minimum.
- Keep route rules close to the code.
- Comment dependencies wherever the reason isn’t obvious.
- Test matching behaviour and the critical endpoint responses.
- Test the route cold — a cache hit skips the code you’re trimming, so it hides broken rules.
- Cover logged-in and logged-out — a plugin that’s dead weight on an anonymous page can be load-bearing for the logged-in version of the same URL.
- Make each rule revertible on its own, and treat production monitoring as part of the rollout, not an afterthought. Watch latency, memory, and fatals after every rollout.
Used like this, selective plugin loading lets a large WordPress application keep the flexibility of plugins while skipping work on requests that never needed the whole application. It’s a pragmatic optimization for the architecture most large WordPress applications actually have.
Leave a Reply