WordPress performance, security and SEO: Core Web Vitals, caching, Wordfence and migrations
One plugin shop hit three problems in a single month: failing Core Web Vitals, a malware flag and constant brute-force login attempts. The work covered speed, security, email delivery and SEO.
- Client
- Plugin shop (plus a multilingual site and a replatformed store)
- My role
- WordPress and WooCommerce developer
- WP Rocket
- Redis object cache
- W3 Total Cache
- Wordfence
- WP Mail SMTP
- SPF / DKIM / DMARC
- Yoast SEO
- Rank Math
- Elementor
- WooCommerce
- mobile LCP (heaviest build)
- 4.6s → 1.8s
- PageSpeed mobile score
- 38 → 87
- cached TTFB
- 1.1s → 180ms
- database size
- 2.1GB → 0.9GB
- malware reinfections in 12 months
- 0
mobile LCP (heaviest build)
PageSpeed mobile score
cached TTFB
database size
malware reinfections in 12 months
The challenge
Three requests usually arrive together: "the site is slow" (Core Web Vitals failing), "we got flagged by Google or the host" (malware or an SEO migration gone wrong), and "we keep getting spam and brute-force hits." One plugin shop hit all three in the same month.
Order emails were also landing in spam, which was costing the client sales.
What I built
- 01
Core Web Vitals programme
LCP image preload with fetchpriority="high", font-display: swap with preloaded fonts, CLS removed from ads, embeds and late-loading fonts, non-critical JS deferred, and INP reduced on heavy builder pages by breaking up long tasks and removing unused Elementor and WPBakery icon packs.
- 02
Caching stack tuning
WP Rocket (page cache, delay JS, remove unused CSS, preload, cache warmup, exclusions for cart, checkout and account) and a Redis object cache with wp_cache_add_non_persistent_groups() for admin-only data. W3 Total Cache on a legacy host where Redis wasn't available.
- 03
Database hygiene
Removed about 1.2GB of orphaned postmeta and transients, fixed autoloaded-options bloat, and added indexes for the store's heavy meta queries.
- 04
Wordfence and server hardening
2FA, login rate limiting, WAF rules, country blocking for admin, XML-RPC and the file editor disabled, .htaccess and server-level hardening, and a least-privilege roles audit.
- 05
Malware cleanup
Two compromised sites (backdoor PHP in uploads, injected spam in post meta) were cleaned with a full core and plugin integrity check and credential rotation.
- 06
Email deliverability
WP Mail SMTP with a transactional provider, plus SPF, DKIM and DMARC alignment. This fixed the "order emails go to spam" problem.
- 07
SEO migrations and cleanup
Yoast to Rank Math migrations (meta, redirects and schema), schema fixes through filters, canonical and hreflang cleanup on the multilingual site, and a redirect map of about 2,300 URLs for a replatform.
Gotchas and how I fixed them
Lazy-loading the hero image is the LCP bug
LCP is usually the hero image. Exclude it from lazy load and add fetchpriority="high". It is a one-line change that often moves LCP by a second.
Delay JS breaks checkout
Delay JS can break checkout and payment redirects. Exclude the gateway, cart, checkout and any script that must run on DOMContentLoaded, and test a real purchase, not just the homepage.
Redis without a flush strategy
Flush on deploy, use a per-environment prefix (WP_REDIS_PREFIX), and never cache user-specific data in a persistent group.
Autoloaded options
WP 6.4+ has wp_set_option_autoload(). Audit with SELECT option_name, LENGTH(option_value) FROM wp_options WHERE autoload='yes' ORDER BY 2 DESC. Plugin leftovers here quietly slow TTFB.
Wordfence can block REST, AJAX and webhooks
Rate limiting and country blocking can break the block editor or a payment webhook. Whitelist the webhook IPs and test /wp-json/ after every WAF change.
Malware cleanup is more than deleting files
Reinstall core and plugins from source, diff uploads against known-good, search postmeta and wp_options for injected scripts, rotate all credentials (DB, salts, API keys), and check cron for rogue scheduled events.
DMARC is the deliverability fix, not SMTP alone
SPF alone doesn't stop spoofing. Start with p=none plus reporting, then move to quarantine or reject once aligned.
Yoast to Rank Math migration gaps
The built-in tool covers most meta, but redirects and schema customizations don't always come across. Verify the redirect table and every schema type afterwards.
Schema filters differ by plugin
Yoast uses wpseo_schema_graph and wpseo_schema_article; Rank Math uses rank_math/schema and rank_math/frontend/description. Copy-pasting a snippet between them silently does nothing.
Code from the build
add_action( 'wp_head', function () {
if ( ! is_singular() ) {
return;
}
$image_id = get_post_thumbnail_id();
if ( ! $image_id ) {
return;
}
$src = wp_get_attachment_image_src( $image_id, 'full' );
if ( ! $src ) {
return;
}
printf(
'<link rel="preload" as="image" href="%s" fetchpriority="high">' . "\n",
esc_url( $src[0] )
);
}, 1 );
// And make sure the LCP image is NOT lazy-loaded.
add_filter( 'wp_get_attachment_image_attributes', function ( $attr, $attachment, $size ) {
if ( 'full' === $size && is_singular() && get_post_thumbnail_id() === $attachment->ID ) {
$attr['loading'] = 'eager';
$attr['fetchpriority'] = 'high';
}
return $attr;
}, 10, 3 );The second filter is the one people forget: the preload only helps if the image itself isn't deferred.
// wp-config.php
define( 'WP_REDIS_HOST', '127.0.0.1' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_REDIS_PREFIX', 'mysite:' );
define( 'WP_REDIS_DATABASE', 0 );
define( 'WP_REDIS_MAXTTL', 86400 );
// Keep admin-only data out of Redis so it can't leak or bloat it.
add_action( 'init', function () {
wp_cache_add_non_persistent_groups( [ 'my_admin', 'my_import_log' ] );
} );
// Flush on deploy — stale page CSS + stale object cache is the classic "ghost bug".
// In your deploy script: wp cache flush && wp transient delete --allWP_REDIS_PREFIX is per environment; without it a staging flush wipes production's cache.
add_filter( 'authenticate', function ( $user, $username ) {
if ( empty( $username ) ) {
return $user;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$key = 'login_fail_' . md5( $ip . '|' . strtolower( $username ) );
$n = (int) get_transient( $key );
if ( $n >= 5 ) {
return new WP_Error( 'too_many_attempts', __( 'Too many attempts. Try again in 15 minutes.', 'my-textdomain' ) );
}
return $user;
}, 30, 2 );
add_action( 'wp_login_failed', function ( $username ) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$key = 'login_fail_' . md5( $ip . '|' . strtolower( $username ) );
set_transient( $key, (int) get_transient( $key ) + 1, 15 * MINUTE_IN_SECONDS );
} );After 5 failed attempts for the same IP and username the login is refused for 15 minutes, with no plugin to deactivate.
// Yoast: change the Article schema type.
add_filter( 'wpseo_schema_article', function ( $data ) {
if ( is_singular( 'case_study' ) ) {
$data['@type'] = 'CaseStudy';
}
return $data;
} );
// Rank Math: equivalent hook.
add_filter( 'rank_math/schema', function ( $data ) {
if ( is_singular( 'case_study' ) && isset( $data['@type'] ) ) {
$data['@type'] = 'CaseStudy';
}
return $data;
} );The hook names are plugin-specific, so a snippet copied from Yoast to Rank Math silently does nothing.
add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'wp_headers', function ( $headers ) {
$headers['X-Content-Type-Options'] = 'nosniff';
$headers['X-Frame-Options'] = 'SAMEORIGIN';
$headers['Referrer-Policy'] = 'strict-origin-when-cross-origin';
$headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()';
return $headers;
} );
// HSTS belongs at the server/proxy layer, not in PHP.Disabling XML-RPC in code survives plugin deactivation, and you should still block it at the server too.
Results
- Mobile LCP on the heaviest build: about 4.6s → about 1.8s. CLS: about 0.24 → about 0.02. INP on the product page: about 380ms → about 140ms.
- PageSpeed mobile score: about 38 → about 87. Cached TTFB: about 1.1s → about 180ms.
- Database size: about 2.1GB → about 0.9GB. Autoloaded options: about 1.8MB → about 240KB.
- Order-email inbox rate: about 82% → about 99% after SMTP and SPF/DKIM/DMARC alignment.
- About 40,000 brute-force login attempts blocked a month.
- Malware cleanup on 2 sites with 0 reinfections in 12 months.
- Replatform redirect map of about 2,300 URLs: no ranking loss on the top 50 pages and about 12 keyword positions improved.
Questions people ask
How do I fix a failing LCP score on WordPress?
LCP is usually the hero image, and lazy-loading it is the bug. Exclude it from lazy load and add fetchpriority="high" plus a preload. On the heaviest build, mobile LCP went from about 4.6s to about 1.8s.
Why does WP Rocket's delay JS break my WooCommerce checkout?
Delayed scripts can stop payment redirects from working. Exclude the gateway, cart, checkout and anything that must run on DOMContentLoaded, then test a real purchase, not just the homepage.
Why do my WooCommerce order emails go to spam?
Use WP Mail SMTP with a transactional provider and align SPF, DKIM and DMARC. SPF alone doesn't stop spoofing, so start DMARC at p=none with reporting, then move to quarantine or reject. For one client the inbox rate went from about 82% to about 99%.
How do you clean a hacked WordPress site properly?
Reinstall core and plugins from source, diff uploads against known-good, search postmeta and wp_options for injected scripts, rotate every credential and check cron for rogue events. Two cleaned sites had no reinfections in 12 months.
Can I switch from Yoast to Rank Math without losing rankings?
Yes, with checks. The built-in tool covers most meta, but redirects and schema customizations don't always come across, so verify both. A replatform with a redirect map of about 2,300 URLs lost no rankings on the top 50 pages.