SEO Plugin Benchmark Methodology
The rig, the controls and the six mistakes that make SEO plugin benchmarks lie — written so you can reproduce our numbers rather than trust them.
On this page
This is the exact procedure behind our seven-plugin WordPress SEO benchmark, written so you can reproduce it — on our numbers or on your own plugins. Every measurement in that post came from this protocol.
We publish it for two reasons. A benchmark you cannot reproduce is marketing, and we found a bug in our own plugin by running it, which is the best argument for running it yourself.
Environment
Use a scratch WordPress install that nothing else touches. Ours:
| Component | Version |
|---|---|
| WordPress | 7.1 |
| PHP | 8.2.27 |
| Database | MySQL 8.0.35 |
| Theme | Twenty Twenty-Five, unmodified |
| Web server | PHP built-in server, single-threaded |
| Other plugins active | None, at any point |
A single-threaded server is a deliberate choice: it removes concurrency noise, so run-to-run variance comes from the machine rather than from request interleaving. It makes the numbers relative rather than production-absolute, which is what a comparison needs.
Content corpus
Hold content perfectly constant. Every plugin must render the same words, headings, images and taxonomy assignments, or you are measuring your corpus rather than the plugin.
- 40 posts, each with identical body structure (two paragraphs, an
h2, a list, anh3), a featured image, one category and two tags - 8 pages
- 6 categories, 15 tags
- 5 uploaded images with alt text
- Permalinks set to
/%postname%/
Featured images matter more than they sound: some plugins include inline content images in sitemaps but exclude featured images by default, and a corpus with only one kind will mis-rank them. State which kind yours uses.
Protocol, per plugin
Run this identically for every plugin and for a no-plugin baseline. The baseline is the whole point — every reported figure is a delta from it.
- Restore the database from one snapshot taken before any SEO plugin was installed. This is the step people skip, and skipping it means plugin N inherits plugin N−1's options.
- Activate exactly one SEO plugin.
- Apply only a plugin's own default configuration. Some plugins gate their front-end output behind a setup wizard and emit nothing until it completes — measuring them un-configured makes them look weightless. Set only what the plugin's own "skip" path sets, and disclose it. In our run, only Rank Math needed this.
- Load the admin two or three times so activation and install routines finish, then flush rewrite rules so sitemap and robots routes resolve.
- Warm up with 6 requests per URL, discarded.
- Measure 13 requests per URL and take the median, not the mean — one background process on your laptop will wreck a mean.
- Deactivate and measure what remains.
Then repeat the entire run a second time and compare. If a metric moves between passes, say so.
Measuring server cost from inside WordPress
External timing alone cannot separate PHP work from network and server overhead. Put a probe in wp-content/mu-plugins/ — must-use plugins load before regular plugins, so everything the plugin under test does falls inside the measured window.
<?php
// wp-content/mu-plugins/00-bench-probe.php
if ( function_exists( 'memory_reset_peak_usage' ) ) {
memory_reset_peak_usage(); // essential on a persistent server process
}
define( 'BENCH_T0', microtime( true ) );
add_action( 'shutdown', function () {
$record = [
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'ms' => round( ( microtime( true ) - BENCH_T0 ) * 1000, 2 ),
'mem_kb' => (int) round( memory_get_peak_usage( true ) / 1024 ),
'queries' => get_num_queries(),
'hooks' => count( $GLOBALS['wp_filter'] ),
];
file_put_contents( '/tmp/probe.jsonl', json_encode( $record ) . "\n", FILE_APPEND );
}, PHP_INT_MAX );
Two details decide whether these numbers mean anything:
memory_reset_peak_usage()is mandatory if your server keeps one PHP process alive across requests.memory_get_peak_usage()is process-wide, so without the reset you record the high-water mark of every request that came before, and every plugin looks identical.- Hook
shutdownatPHP_INT_MAXso you measure after the page is fully rendered, not mid-request.
What to record
Cost metrics
| Metric | Source | Why it matters |
|---|---|---|
| PHP time | probe | The plugin's own render cost |
| Peak memory | probe | Headroom on shared hosting |
| Query count | get_num_queries() | Scales badly under traffic |
| Hooks registered | count($GLOBALS['wp_filter']) | Integration surface and indirect cost |
| Autoloaded option bytes | SQL, below | Read on every request, always |
| Disk size and file count | du, find | Update and backup weight |
| Custom tables | SHOW TABLES diff | Schema you now own permanently |
| Cron events | wp cron event list | Recurring background work |
Autoloaded options are the most under-reported number in WordPress performance:
SELECT ROUND(SUM(LENGTH(option_value)) / 1024, 1) AS autoload_kb
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on');
Modern WordPress uses several autoload values, so matching only 'yes' silently under-reports. Take this reading with the plugin active and after deactivating it — a deactivated plugin's autoloaded options are still loaded on every request.
Output metrics
Fetch one representative post and count what the plugin actually printed:
- Total
<head>bytes, compared to baseline - External
<script src>and stylesheet<link>tags — then fetch each and sum the real bytes, because a count without sizes hides a 4 MB bundle - Inline
<script>bytes, excludingapplication/ld+jsonblocks, or you will report a plugin's schema as JavaScript weight - JSON-LD: block count, total bytes, and the distinct
@typevalues in the graph <meta name="description">,<link rel="canonical">,og:*count,twitter:*count
Check the canonical tag explicitly. WordPress core emits one by itself, so a plugin can score worse than no plugin here — which is exactly what we found in Hydrogen SEO 0.0.14 (fixed in 0.0.15), and we would not have caught it any other way. See canonical URLs for why that tag carries weight.
Sitemaps
Discover the sitemap the way a crawler does — read the Sitemap: line from /robots.txt rather than guessing a filename. Plugins disagree: /sitemap.xml, /sitemap_index.xml and /sitemaps.xml are all in use.
Then follow the index into every child sitemap. Counting <url> on the index alone returns zero for every plugin that uses an index, which is the single easiest way to publish a wrong sitemap comparison. In each child, count <url>, <image:image> and <lastmod>, and validate the XML parses. Watch for <loc> values wrapped in CDATA — a naive regex misses them entirely.
Admin and editor surface
Authenticate properly and measure the screens people actually use: the dashboard, the plugins list, and the block editor.
Generating a session cookie directly is far more reliable than scripting the login form:
<?php
// run through WP-CLI: wp eval-file mkcookie.php
$uid = 1;
$expiry = time() + 86400;
$token = WP_Session_Tokens::get_instance( $uid )->create( $expiry );
printf(
"wordpress_%s\t%s\nwordpress_logged_in_%s\t%s\n",
COOKIEHASH, wp_generate_auth_cookie( $uid, $expiry, 'auth', $token ),
COOKIEHASH, wp_generate_auth_cookie( $uid, $expiry, 'logged_in', $token )
);
You need both cookies: wp-admin validates the auth cookie, not just logged_in. Regenerate them after every database restore, because the restore wipes the session tokens.
On each screen record page HTML size, response time, total script and stylesheet counts, and — the useful one — only the assets served from that plugin's own directory, fetched and summed:
(?:src|href)="[^"]*/plugins/<slug>/[^"]+"
That isolates the plugin's contribution from WordPress core's, and it is where the widest spread in our whole benchmark appeared.
Reporting honestly
- Publish the baseline. A delta without its reference point is unfalsifiable.
- Separate deterministic from noisy metrics. Memory, queries, byte counts and option sizes were identical across both of our passes. Timing moved about ±10%. Rank on the stable metrics; report timing as a relative cost with its variance stated.
- Never report a metric you have not verified end to end. We nearly published "Rank Math ships no sitemap images" before checking its settings and finding
include_imageson withinclude_featured_imageoff — a completely different, and correct, statement. - Distinguish defaults from capability. "No image entries at default settings" is a finding. "Cannot do image sitemaps" is usually false.
- Disclose your conflict of interest and report your own losses. We build one of the plugins tested. It lost most categories and had the worst single defect in the run. Publishing that is the only thing that makes the rest of the numbers worth reading.