Skip to content
Get a free audit

Currency

Project Spark 9 min read

Performance at 10,000 Posts: Cache Priming and Autoload Hygiene

Performance engineering for large WordPress sites, cache priming that cut 110,000 queries, autoload hygiene, stampede locks, and batched scanning.

Bernard

SEO plugins are notoriously resource-heavy because their job, analyzing every post, is inherently O(everything). Project Spark had to run site-wide audits, scoring, and link analysis on sites with thousands of posts without overloading shared hosting. Four techniques did most of the work, and each generalizes far beyond SEO.

Key takeaway: The performance killers in WordPress plugins are rarely slow algorithms, they’re N+1 database queries, bloated autoloaded options, cache stampedes, and unbounded scans. All four have cheap, boring fixes.

Why SEO plugins are a worst case for performance

Most plugins touch one post at a time, the one being viewed or edited. An SEO plugin’s most valuable features are the opposite: site-wide audit reports, coverage statistics, link graphs, sitemap generation, content-decay scans. Every one of those features asks the same expensive question, “tell me something about every published post”, and asks it on infrastructure the developer does not control. The client might be on a tuned dedicated server; the next site running the same code might be on shared hosting with a 128 MB memory limit and a database on the other side of a slow network link.

That combination, inherently whole-site workloads on unknown, often modest hardware, means performance cannot be an optimization pass at the end. It has to be a design constraint from the first scanner you write. What follows are the four constraints that mattered, each discovered the honest way: by finding our own code violating it.

1. Cache priming: the 110,000-query fix

The classic WordPress trap: loop over posts, read metadata per post. Each read that misses the object cache is a database query. An audit reading 11 meta fields per post on a 10,000-post site issues 110,000 queries, for one export command we found doing exactly that.

The mechanics deserve spelling out, because the trap is invisible in the code. A loop that calls “get this post’s title, description, keywords…” looks innocent; each call is one line. But on a cold cache, each call becomes a round trip to the database. Eleven fields, ten thousand posts: the multiplication happens silently at runtime, never in the source. The command worked, it just hammered the database for minutes on a site large enough, and on shared hosting that can mean the whole site slows for every visitor while the export runs.

The fix is one line per loop: prime the metadata cache for all post IDs before the loop, collapsing per-post queries into a single batched one. WordPress ships the batching primitives; they are simply easy to forget. After priming, the same export issues a handful of queries, the measured difference on the worst case was five orders of magnitude.

One fix is a patch; a sweep is a repair. We audited every per-post loop in the codebase and found thirteen unprimed scanners, audits, sitemap analysis, dashboard widgets, CLI exports, link graphing. Some looped over a bounded hundred posts, some over everything. All thirteen got the same one-line prime. And because a fix that can silently regress is only half a fix, query-count regression tests now assert an upper bound on queries at scale: a test creates a known number of posts, runs the scanner, counts actual database queries, and fails the build if the count exceeds the bound. An unprimed loop cannot quietly return.

2. Autoload hygiene: what loads on every page

WordPress loads all “autoloaded” options into memory on every request, admin, frontend, AJAX, everything. Store a 5 KB AI-persona blob or encrypted token set with default autoloading and you’ve taxed every pageview on the site. The tax is invisible in any single request and permanent across all of them.

The discipline is classification. Every stored option answers one question: who reads this, and when? A boolean flag checked while rendering every page belongs in the autoloaded set, that is what autoloading is for. A multi-kilobyte persona text read only when an administrator opens an AI screen does not. An encrypted token blob read only during API calls does not. A one-shot migration flag read twice in the plugin’s lifetime certainly does not. Our sweep classified every option in the plugin by read frequency, moved the admin-only blobs and tokens out of the autoloaded set, and documented the rationale beside each decision.

Two traps made this harder than it sounds. First, a subtle platform behaviour: updating an option that was deleted and re-created can silently re-enable autoloading, which is how our disconnect-and-reconnect flow briefly autoloaded encrypted token blobs onto every pageview. The write path looked identical; only the option’s lifecycle differed. Second, the initial creation path and the update path are usually different code, fixing one and not the other means fresh installations behave differently from upgraded ones. Both paths needed the same explicit flag.

As with the query counts, tests enforce the outcome, not the intention: they query the actual autoload column in the database for the specific options and fail if a blob has crept back into the every-request set.

3. Stampede locks: when caches expire under load

When a popular cached value (a link graph, a redirect map) expires under concurrent traffic, every simultaneous request rebuilds it, the “thundering herd.” One expiry, forty simultaneous rebuilds, each doing the identical expensive work, each writing the identical result. On a busy site this converts a routine cache refresh into a load spike precisely when the site is busiest.

Short-lived locks around expensive rebuilds mean one request rebuilds while the rest serve a fallback. The pattern is simple; the correctness details are where production incidents live:

  • Release the lock in a finally-style cleanup, so a rebuild that crashes mid-way cannot leave the lock stuck. A stuck lock converts “one slow rebuild” into “nobody can rebuild for the lock’s lifetime”, a worse failure than the stampede it prevents.
  • Give the lock a short expiry regardless, as a second safety net. Ten to thirty seconds bounds the damage of any failure mode the cleanup misses.
  • Never let fallback responses pollute the cache. The request that lost the lock race serves degraded content, raw instead of fully rendered, previous instead of fresh. If that degraded value gets written into the cache, the “temporary” fallback becomes everyone’s reality for the next cache lifetime. Fallbacks must be served and forgotten.
  • Scope invalidation carefully. Clearing the content cache must not clear a rebuild lock held by an in-flight request, otherwise the invalidation invites the herd back in mid-rebuild.

Each of those subtleties carries a regression test, because each one is invisible in a code review six months later.

4. Batching and caps: nothing unbounded, ever

Every site-wide scan carries a cap and a batch size: link graphs process posts in batches of 25 with the object cache cleared between batches (memory stays flat instead of accumulating every post body), audits cap at bounds documented in the interface, exports paginate. On shared hosting with 128 MB memory limits, an unbounded scan is a white-screen generator: WordPress caches every loaded post object in memory, so a loop that touches ten thousand posts eventually holds ten thousand post bodies at once unless it deliberately releases them.

The cap sizes are judgment calls, and they are documented as such. Batch-of-25 is small enough to keep memory flat and large enough that the per-batch overhead stays negligible; a scan cap of a few hundred posts covers the overwhelming majority of real sites while protecting the outliers. What matters more than the specific numbers is a rule about honesty: where caps trim results, the interface says so. A report that silently analyzed the first 500 posts of 8,000 reads as “we checked everything” when it didn’t, and an operator making decisions on silently truncated data has been misled by the tool that was supposed to inform them. Silent truncation is a lie of omission; a visible “showing the most recent 500” is a design decision.

Measure before fixing: how each problem was found

None of these four problems announces itself, so the finding technique matters as much as the fix. The query explosions were located by instrumented counting: run a scanner against a site with a known number of posts, count the actual database queries issued, and compare against what the operation logically requires. The gap between “logically needs about five queries” and “issued 110,000” is not subtle once you look, but nobody looks until the measurement exists. The same instrumentation then became the regression test, which is the pattern worth copying: the diagnostic you build to find a problem is usually one assertion away from being the test that prevents its return.

The autoload bloat was found by querying the options table directly, listing every autoloaded option with its size, sorted largest first. It is a one-line database query, it takes thirty seconds, and it is worth running on any WordPress site you maintain today: most sites accumulate autoloaded debris from years of installed-then-removed plugins, and the top of that sorted list is almost always a surprise.

The stampede and memory risks were found by reasoning rather than measurement, reviewing every cached rebuild for what happens when many requests hit an expired cache simultaneously, and every loop for what accumulates in memory across ten thousand iterations. That kind of review is cheap at design time and expensive at incident time; the entire discipline is choosing the earlier price.

A performance checklist for plugin authors

Condensed from the four constraints, in the order that pays off fastest:

  1. Find every loop over posts, and prime before it. One line, five orders of magnitude on the worst cases.
  2. Classify every option by read frequency. Frontend-read flags autoload; blobs, tokens, and admin-only data do not. Check both the create and update paths.
  3. Lock expensive rebuilds. Guaranteed release, short expiry, fallbacks that never enter the cache.
  4. Cap and batch every site-wide operation, clear object caches between batches, and disclose every cap in the interface.
  5. Turn each fix into a measurable test, query counts, autoload column values, lock lifecycles, memory-stable batching, so the build fails before the customer does.

The meta-lesson

None of this is clever. Priming, autoload flags, locks, batching, every fix is boring and two decades old. What made them stick was the same discipline as Part 14: each one is enforced by a test (query-count bounds, autoload-column assertions, lock-lifecycle checks), so performance regressions fail CI instead of failing customers. Performance work that lives only in one developer’s habits leaves with that developer; performance work encoded as failing builds outlives everyone.

Next: Part 17, An Accessible, RTL-Ready Admin UI

FAQ

Why is my WordPress admin slow with many posts?

Usually N+1 query patterns, plugins reading metadata inside per-post loops without priming the cache, plus bloated autoloaded options loading on every request.

What are autoloaded options in WordPress?

Options loaded into memory on every single request. Small, frequently-read values belong there; large blobs, API tokens, and admin-only data should be stored with autoloading disabled.

What is a cache stampede?

When a cached value expires under concurrent traffic and many requests rebuild it simultaneously. Prevent it with a short-lived rebuild lock plus a fallback response.

How do I test performance regressions?

Assert measurable bounds in CI: maximum database queries for a scan at a given post count, autoload flags on specific options, and memory-stable batching for site-wide operations.

How many database queries is too many for one page load?

There is no universal number, but a single request issuing thousands of queries almost always signals an unprimed per-post loop. Well-behaved scans batch reads and stay in the low dozens.

Does cleaning autoloaded options make a website faster?

Yes, measurably, every autoloaded byte is loaded on every request site-wide. Moving large blobs, tokens, and admin-only data out of the autoloaded set reduces memory and lookup cost everywhere.

Keep reading in this series

Keep reading

Performance at 10,000 Posts: Cache Priming and Autoload Hygiene

Performance engineering for large WordPress sites — cache priming that cut 110,000 queries, autoload hygiene, stampede locks, and batched scanning.

Bernard

Building a Hebrew-First SEO Plugin (“Project Spark”) — Part 16 of 20

SEO plugins are notoriously resource-heavy because their job — analyzing every post — is inherently O(everything). Project Spark had to run site-wide audits, scoring, and link analysis on sites with thousands of posts without overloading shared hosting. Four techniques did most of the work, and each generalizes far beyond SEO.

Key takeaway: The performance killers in WordPress plugins are rarely slow algorithms — they’re N+1 database queries, bloated autoloaded options, cache stampedes, and unbounded scans. All four have cheap, boring fixes.

Why SEO plugins are a worst case for performance

Most plugins touch one post at a time — the one being viewed or edited. An SEO plugin’s most valuable features are the opposite: site-wide audit reports, coverage statistics, link graphs, sitemap generation, content-decay scans. Every one of those features asks the same expensive question — “tell me something about every published post” — and asks it on infrastructure the developer does not control. The client might be on a tuned dedicated server; the next site running the same code might be on shared hosting with a 128 MB memory limit and a database on the other side of a slow network link.

That combination — inherently whole-site workloads on unknown, often modest hardware — means performance cannot be an optimization pass at the end. It has to be a design constraint from the first scanner you write. What follows are the four constraints that mattered, each discovered the honest way: by finding our own code violating it.

1. Cache priming: the 110,000-query fix

The classic WordPress trap: loop over posts, read metadata per post. Each read that misses the object cache is a database query. An audit reading 11 meta fields per post on a 10,000-post site issues 110,000 queries — for one export command we found doing exactly that.

The mechanics deserve spelling out, because the trap is invisible in the code. A loop that calls “get this post’s title, description, keywords…” looks innocent; each call is one line. But on a cold cache, each call becomes a round trip to the database. Eleven fields, ten thousand posts: the multiplication happens silently at runtime, never in the source. The command worked — it just hammered the database for minutes on a site large enough, and on shared hosting that can mean the whole site slows for every visitor while the export runs.

The fix is one line per loop: prime the metadata cache for all post IDs before the loop, collapsing per-post queries into a single batched one. WordPress ships the batching primitives; they are simply easy to forget. After priming, the same export issues a handful of queries — the measured difference on the worst case was five orders of magnitude.

One fix is a patch; a sweep is a repair. We audited every per-post loop in the codebase and found thirteen unprimed scanners — audits, sitemap analysis, dashboard widgets, CLI exports, link graphing. Some looped over a bounded hundred posts, some over everything. All thirteen got the same one-line prime. And because a fix that can silently regress is only half a fix, query-count regression tests now assert an upper bound on queries at scale: a test creates a known number of posts, runs the scanner, counts actual database queries, and fails the build if the count exceeds the bound. An unprimed loop cannot quietly return.

2. Autoload hygiene: what loads on every page

WordPress loads all “autoloaded” options into memory on every request — admin, frontend, AJAX, everything. Store a 5 KB AI-persona blob or encrypted token set with default autoloading and you’ve taxed every pageview on the site. The tax is invisible in any single request and permanent across all of them.

The discipline is classification. Every stored option answers one question: who reads this, and when? A boolean flag checked while rendering every page belongs in the autoloaded set — that is what autoloading is for. A multi-kilobyte persona text read only when an administrator opens an AI screen does not. An encrypted token blob read only during API calls does not. A one-shot migration flag read twice in the plugin’s lifetime certainly does not. Our sweep classified every option in the plugin by read frequency, moved the admin-only blobs and tokens out of the autoloaded set, and documented the rationale beside each decision.

Two traps made this harder than it sounds. First, a subtle platform behaviour: updating an option that was deleted and re-created can silently re-enable autoloading — which is how our disconnect-and-reconnect flow briefly autoloaded encrypted token blobs onto every pageview. The write path looked identical; only the option’s lifecycle differed. Second, the initial creation path and the update path are usually different code — fixing one and not the other means fresh installations behave differently from upgraded ones. Both paths needed the same explicit flag.

As with the query counts, tests enforce the outcome, not the intention: they query the actual autoload column in the database for the specific options and fail if a blob has crept back into the every-request set.

3. Stampede locks: when caches expire under load

When a popular cached value (a link graph, a redirect map) expires under concurrent traffic, every simultaneous request rebuilds it — the “thundering herd.” One expiry, forty simultaneous rebuilds, each doing the identical expensive work, each writing the identical result. On a busy site this converts a routine cache refresh into a load spike precisely when the site is busiest.

Short-lived locks around expensive rebuilds mean one request rebuilds while the rest serve a fallback. The pattern is simple; the correctness details are where production incidents live:

  • Release the lock in a finally-style cleanup, so a rebuild that crashes mid-way cannot leave the lock stuck. A stuck lock converts “one slow rebuild” into “nobody can rebuild for the lock’s lifetime” — a worse failure than the stampede it prevents.
  • Give the lock a short expiry regardless, as a second safety net. Ten to thirty seconds bounds the damage of any failure mode the cleanup misses.
  • Never let fallback responses pollute the cache. The request that lost the lock race serves degraded content — raw instead of fully rendered, previous instead of fresh. If that degraded value gets written into the cache, the “temporary” fallback becomes everyone’s reality for the next cache lifetime. Fallbacks must be served and forgotten.
  • Scope invalidation carefully. Clearing the content cache must not clear a rebuild lock held by an in-flight request — otherwise the invalidation invites the herd back in mid-rebuild.

Each of those subtleties carries a regression test, because each one is invisible in a code review six months later.

4. Batching and caps: nothing unbounded, ever

Every site-wide scan carries a cap and a batch size: link graphs process posts in batches of 25 with the object cache cleared between batches (memory stays flat instead of accumulating every post body), audits cap at bounds documented in the interface, exports paginate. On shared hosting with 128 MB memory limits, an unbounded scan is a white-screen generator: WordPress caches every loaded post object in memory, so a loop that touches ten thousand posts eventually holds ten thousand post bodies at once unless it deliberately releases them.

The cap sizes are judgment calls, and they are documented as such. Batch-of-25 is small enough to keep memory flat and large enough that the per-batch overhead stays negligible; a scan cap of a few hundred posts covers the overwhelming majority of real sites while protecting the outliers. What matters more than the specific numbers is a rule about honesty: where caps trim results, the interface says so. A report that silently analyzed the first 500 posts of 8,000 reads as “we checked everything” when it didn’t — and an operator making decisions on silently truncated data has been misled by the tool that was supposed to inform them. Silent truncation is a lie of omission; a visible “showing the most recent 500” is a design decision.

Measure before fixing: how each problem was found

None of these four problems announces itself, so the finding technique matters as much as the fix. The query explosions were located by instrumented counting: run a scanner against a site with a known number of posts, count the actual database queries issued, and compare against what the operation logically requires. The gap between “logically needs about five queries” and “issued 110,000” is not subtle once you look — but nobody looks until the measurement exists. The same instrumentation then became the regression test, which is the pattern worth copying: the diagnostic you build to find a problem is usually one assertion away from being the test that prevents its return.

The autoload bloat was found by querying the options table directly — listing every autoloaded option with its size, sorted largest first. It is a one-line database query, it takes thirty seconds, and it is worth running on any WordPress site you maintain today: most sites accumulate autoloaded debris from years of installed-then-removed plugins, and the top of that sorted list is almost always a surprise.

The stampede and memory risks were found by reasoning rather than measurement — reviewing every cached rebuild for what happens when many requests hit an expired cache simultaneously, and every loop for what accumulates in memory across ten thousand iterations. That kind of review is cheap at design time and expensive at incident time; the entire discipline is choosing the earlier price.

A performance checklist for plugin authors

Condensed from the four constraints, in the order that pays off fastest:

  1. Find every loop over posts, and prime before it. One line, five orders of magnitude on the worst cases.
  2. Classify every option by read frequency. Frontend-read flags autoload; blobs, tokens, and admin-only data do not. Check both the create and update paths.
  3. Lock expensive rebuilds. Guaranteed release, short expiry, fallbacks that never enter the cache.
  4. Cap and batch every site-wide operation, clear object caches between batches, and disclose every cap in the interface.
  5. Turn each fix into a measurable test — query counts, autoload column values, lock lifecycles, memory-stable batching — so the build fails before the customer does.

The meta-lesson

None of this is clever. Priming, autoload flags, locks, batching — every fix is boring and two decades old. What made them stick was the same discipline as Part 14: each one is enforced by a test (query-count bounds, autoload-column assertions, lock-lifecycle checks), so performance regressions fail CI instead of failing customers. Performance work that lives only in one developer’s habits leaves with that developer; performance work encoded as failing builds outlives everyone.

FAQ

Why is my WordPress admin slow with many posts?

Usually N+1 query patterns — plugins reading metadata inside per-post loops without priming the cache — plus bloated autoloaded options loading on every request.

What are autoloaded options in WordPress?

Options loaded into memory on every single request. Small, frequently-read values belong there; large blobs, API tokens, and admin-only data should be stored with autoloading disabled.

What is a cache stampede?

When a cached value expires under concurrent traffic and many requests rebuild it simultaneously. Prevent it with a short-lived rebuild lock plus a fallback response.

How do I test performance regressions?

Assert measurable bounds in CI: maximum database queries for a scan at a given post count, autoload flags on specific options, and memory-stable batching for site-wide operations.

How many database queries is too many for one page load?

There is no universal number, but a single request issuing thousands of queries almost always signals an unprimed per-post loop. Well-behaved scans batch reads and stay in the low dozens.

Does cleaning autoloaded options make a website faster?

Yes, measurably — every autoloaded byte is loaded on every request site-wide. Moving large blobs, tokens, and admin-only data out of the autoloaded set reduces memory and lookup cost everywhere.


Previous: Part 15: The ‘0’ That Broke Everything: PHP Falsy Bugs
Next: Part 17: Building an Accessible, RTL-Ready Admin UI

Keep reading

Newsletter

Get the next playbook in your inbox.

One short, no-fluff email per fortnight.

Ready when you are

Let's map the next 90 days of growth.

Book a no-pressure call. We'll review your funnel, share quick wins, and outline what compounding growth could look like for your business.

Get a free audit

A note on cookies

We use cookies to measure how this site performs and to make it better. Analytics cookies only run if you accept.

Read our privacy policy