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.

5 min read
On this page
  1. Environment
  2. Content corpus
  3. Protocol, per plugin
  4. Measuring server cost from inside WordPress
  5. What to record
  6. Cost metrics
  7. Output metrics
  8. Sitemaps
  9. Admin and editor surface
  10. Reporting honestly
  11. Related reading

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:

ComponentVersion
WordPress7.1
PHP8.2.27
DatabaseMySQL 8.0.35
ThemeTwenty Twenty-Five, unmodified
Web serverPHP built-in server, single-threaded
Other plugins activeNone, 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, an h3), 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.

  1. 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.
  2. Activate exactly one SEO plugin.
  3. 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.
  4. Load the admin two or three times so activation and install routines finish, then flush rewrite rules so sitemap and robots routes resolve.
  5. Warm up with 6 requests per URL, discarded.
  6. Measure 13 requests per URL and take the median, not the mean — one background process on your laptop will wreck a mean.
  7. 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
<?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 shutdown at PHP_INT_MAX so you measure after the page is fully rendered, not mid-request.

What to record

Cost metrics

MetricSourceWhy it matters
PHP timeprobeThe plugin's own render cost
Peak memoryprobeHeadroom on shared hosting
Query countget_num_queries()Scales badly under traffic
Hooks registeredcount($GLOBALS['wp_filter'])Integration surface and indirect cost
Autoloaded option bytesSQL, belowRead on every request, always
Disk size and file countdu, findUpdate and backup weight
Custom tablesSHOW TABLES diffSchema you now own permanently
Cron eventswp cron event listRecurring background work

Autoloaded options are the most under-reported number in WordPress performance:

sql
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, excluding application/ld+json blocks, or you will report a plugin's schema as JavaScript weight
  • JSON-LD: block count, total bytes, and the distinct @type values 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
<?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:

code
(?: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_images on with include_featured_image off — 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.
Was this page helpful?
Still stuck? Contact support