Every security plugin vendor publishes a performance claim, and none of them are measuring your site. The host, the PHP version, the opcode cache, the traffic mix and whatever else is installed all move the result more than the plugin does. I am not going to give you a percentage in this post, because I do not have one I would defend in a code review.
What I can give you is the method. Half an hour of setup produces numbers about your stack, and those numbers settle the argument that vendor benchmarks never will.
Three things are worth measuring separately, because they behave differently and get confused constantly:
- Per-request cost. PHP executed on every hit, whether or not anything security-relevant happened.
- Write cost. Rows inserted per event the plugin decides to record. Scales with traffic, not with legitimate traffic.
- Storage cost. What the accumulated records weigh, and specifically how much of it WordPress loads into memory on every single request. The third one is where the hundred-megabyte options-table stories come from, and it is almost always a logging feature rather than a firewall.
Per-request cost: median TTFB, not one curl
A single curl -w is noise. Take the median of a run and compare medians.
#!/usr/bin/env bash
# ttfb.sh - median time-to-first-byte over N requests
# usage: ./ttfb.sh https://staging.example.com/ 40
set -euo pipefail
URL="${1:?usage: ttfb.sh <url> [n]}"
N="${2:-40}"
TMP=$(mktemp)
for _ in $(seq "$N"); do
curl -sS -o /dev/null \
-H 'Cache-Control: no-cache' \
-w '%{time_starttransfer}\n' \
--max-time 20 "$URL" >> "$TMP"
sleep 0.4
done
sort -g "$TMP" | awk -v n="$N" '
{ v[NR] = $1 }
END {
printf "n=%d min=%.3f p50=%.3f p90=%.3f max=%.3f\n",
NR, v[1], v[int(NR*0.5)+0], v[int(NR*0.9)+0], v[NR]
}'
rm -f "$TMP"
Rules that make the output mean something:
- Run it against staging, not production, and against a URL your page cache does not serve. A cached response measures your cache, not your plugin stack.
- Send
Cache-Control: no-cacheand pick a path with a query string if your cache is aggressive. - Compare p50 and p90 from the same machine, at the same time of day, on the same host. Cross-host comparisons are worthless. Now the A/B. Deactivate one plugin, re-measure, reactivate:
#!/usr/bin/env bash
# ab-plugin.sh - measure with a plugin off, then back on
set -euo pipefail
URL="$1"; SLUG="$2"
echo "== baseline (all active)"; ./ttfb.sh "$URL" 40
wp plugin deactivate "$SLUG" >/dev/null
echo "== without $SLUG"; ./ttfb.sh "$URL" 40
wp plugin activate "$SLUG" >/dev/null
echo "== restored"; ./ttfb.sh "$URL" 40
The third run matters. If "restored" does not land near "baseline", something else moved during the test and the middle number is not trustworthy.
For attribution inside a single page load rather than a difference between two, Query Monitor breaks queries and hook time down per plugin on a live request, and the WP-CLI profile package does it from the shell:
wp package install wp-cli/profile-command:@stable
wp profile stage --allow-root
wp profile hook --all --spotlight --orderby=time --order=desc | head -20
Storage cost: aggregate autoload weight by plugin, not by option
Total autoloaded weight first. This is what gets pulled into memory on every request, including requests that have nothing to do with security:
SELECT SUM(LENGTH(option_value)) AS autoload_bytes,
COUNT(*) AS autoload_rows
FROM wp_options
WHERE autoload IN ('yes','on','auto','auto-on');
The accepted values in the autoload column were expanded in recent WordPress releases, so the IN list keeps this working across versions. Anything past a few hundred kilobytes is worth chasing.
Listing the top twenty options is the usual next step, and it is fine, but it buries a plugin that autoloads two hundred small options under one plugin that autoloads a single large one. Aggregate by prefix instead:
SELECT
SUBSTRING_INDEX(TRIM(LEADING '_' FROM option_name), '_', 1) AS prefix,
COUNT(*) AS rows_n,
ROUND(SUM(LENGTH(option_value)) / 1024, 1) AS kb
FROM wp_options
WHERE autoload IN ('yes','on','auto','auto-on')
GROUP BY prefix
HAVING kb > 5
ORDER BY kb DESC
LIMIT 25;
Option names carry the prefix of the plugin that created them, so this names the responsible plugin directly and ranks by what it actually costs rather than by the size of its largest single row. Core's own prefixes show up too, which is a useful sanity check on the query.
Through WP-CLI, if you would rather not open a SQL client:
wp option list --autoload=on --fields=option_name,size_bytes \
--format=csv \
| awk -F, 'NR>1 { split($1, p, "_"); k = p[1] ? p[1] : p[2]; s[k] += $2 }
END { for (i in s) printf "%10.1f KB %s\n", s[i]/1024, i }' \
| sort -rn | head -20
And table sizes, where accumulated logs live rather than autoloaded settings:
SELECT table_name,
table_rows,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS mb
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 15;
Two things to rule out before blaming security tooling. Transients that never expired are a common cause of options growth and usually come from caching or import plugins. And orphaned tables from plugins deleted years ago keep every row, because uninstalling removes the code and not the data.
Write cost: measure the growth rate, not the size
A table's current size tells you where you are. Its growth rate tells you where you will be in six months, and that is the number that decides whether a logging feature is sustainable on this site.
Sample it on a schedule:
#!/usr/bin/env bash
# table-growth.sh - append a size sample; run hourly from cron
set -euo pipefail
LOG=/var/log/wp-table-sizes.csv
[ -f "$LOG" ] || echo "ts,table,mb,rows" > "$LOG"
wp db query "
SELECT table_name, ROUND((data_length+index_length)/1024/1024,3), table_rows
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
ORDER BY (data_length+index_length) DESC LIMIT 15;" --skip-column-names \
| awk -v ts="$(date +%FT%T)" 'BEGIN{OFS=","} {print ts, $1, $2, $3}' >> "$LOG"
# crontab -e
17 * * * * /usr/local/bin/table-growth.sh
After a couple of days, bytes per day per table:
awk -F, 'NR>1 { if (!(($2) in first)) { first[$2]=$3; ft[$2]=$1 } last[$2]=$3; lt[$2]=$1 }
END { for (t in last)
printf "%-40s %8.2f MB now %+7.2f MB since %s\n",
t, last[t], last[t]-first[t], ft[t] }' \
/var/log/wp-table-sizes.csv | sort -k2 -rn
Run it across a weekday and a weekend. Log tables that grow with bot traffic have a very different curve from tables that grow with human activity, and knowing which one you have tells you whether a retention setting fixes it or whether you need the traffic to stop arriving.
Before you delete anything: most logging features ship a retention or pruning setting that defaults to keeping everything, and that is the first place to look. Deleting options a plugin actively reads will break it, and some log tables are referenced by the plugin's own admin screens. Back up first.
What the numbers will not tell you
Two limits worth stating, because a set of scripts like this invites over-reading.
A low blocked-attack count on a dashboard is ambiguous. An operator running fail2ban across two servers posted his jail counts on r/Wordpress this month: crawlers banned 915 and 1,129, the wp-login jails 15,189 and 50,386, the xmlrpc jails 11,345 and 9,530. Those are his counters on his servers, not a general measurement. His conclusion is the useful part and it generalises fine: those bans happen at the server before PHP starts, so no plugin sees that traffic and none of it appears in any dashboard. A low count can mean less attack traffic, or it can mean something upstream absorbed it. From inside WordPress those look identical.
Cheap layers cannot see everything. A rule evaluated before PHP starts can only answer questions that can be settled from the request itself. It does not know who is logged in or whether a user has the capability they are claiming. Broken access control was the largest single category Patchstack's RapidMitigate blocked in 2025, at 57% of attacks, precisely because it resembles normal authenticated traffic. Nothing at the rewrite layer catches that, and moving enforcement earlier trades visibility and context for cost.
So this answers "what does this cost me", which is a real question with a measurable answer. It does not answer "is this the right control", which depends on what you are defending against.
One question
If you have chased an options table into the hundreds of megabytes: what was actually at the top of the list when you aggregated by prefix? Mine has been a caching plugin more often than a security plugin, and I would like to know whether that holds for anyone else.
I run engineering at Squirrly and spend more time in access logs than I would choose to. The article this accompanies, with the layer comparison in full, is here: https://wpghost.com/lightweight-wordpress-security-plugin/