Si votre page “charge vite” à l’œil mais que PageSpeed vous pénalise sur LCP, INP ou CLS, c’est souvent parce que le navigateur n’exécute pas vos priorités… mais celles imposées par votre HTML, vos scripts et vos styles.

WordPress 6.9.4 (avril 2026) fait déjà pas mal de choses correctement (lazy-loading, attributs d’images, etc.). Le vrai gain vient des ajustements fins : prioriser le bon fichier au bon moment, éviter les tâches longues JS, et réserver l’espace pour stopper les sauts de mise en page.


Le problème de performance

Les Core Web Vitals se dégradent typiquement sur des sites WordPress avec un “hero” lourd (LCP), des bundles JS massifs (INP), et des blocs dynamiques (CLS) générés par un page builder (Divi 5, Elementor, Avada) ou des plugins marketing.

Concrètement :

  • LCP (Largest Contentful Paint) grimpe quand l’élément principal (souvent une image ou un titre) arrive trop tard : image non préchargée, CSS bloquant, TTFB élevé, CDN mal configuré.
  • INP (Interaction to Next Paint) explose quand un clic déclenche une tâche longue JS (handlers lourds, sliders, analytics, scripts tiers) ou quand le main thread est saturé.
  • CLS (Cumulative Layout Shift) augmente quand la page bouge après affichage : images sans dimensions, polices web qui “swap”, embeds qui changent de taille, barres admin/consentement qui poussent le contenu.

Impact réel : baisse SEO, hausse du taux de rebond mobile, et conversions en berne sur les pages d’atterrissage. J’ai souvent vu un site “correct” passer de 65 à 90+ sur mobile juste en fixant 3 choses : hero LCP, un script tiers, et des dimensions d’images.

À la fin, vous saurez :

  • mesurer LCP/INP/CLS en conditions réelles (RUM) avec du JS léger,
  • prioriser l’élément LCP avec preload, fetchpriority et des tailles correctes,
  • réduire l’INP en évitant les tâches longues et en retardant ce qui peut l’être,
  • supprimer les CLS en réservant l’espace (images, iframes, polices, modules),
  • corriger les goulots serveur (DB, transients, cache objet) qui aggravent LCP.

Résumé rapide

  • LCP : préchargez l’image hero (ou la ressource LCP) et donnez-lui fetchpriority="high" ; évitez de la lazy-loader.
  • INP : passez en defer ce qui n’est pas critique, découpez les handlers lourds, utilisez des listeners {passive:true} et évitez le travail synchrone au clic.
  • CLS : imposez width/height ou aspect-ratio, stabilisez les iframes/embeds, et gérez les polices (preload + fallback métrique).
  • TTFB : traquez les requêtes lentes (Query Monitor + slow query log), mettez en cache (transients + cache objet), et éliminez les requêtes répétées.
  • Mesurez : instrumentez vos pages (RUM) et comparez avant/après avec des chiffres (p75, pas juste un score).

Diagnostic avec du code

1) Activer un mode debug “performance” sans casser la prod

Sur un environnement de staging (ou en prod mais avec précautions), activez des logs propres. Évitez WP_DEBUG_DISPLAY en prod, ça peut casser le HTML et fausser les métriques.

<?php
// wp-config.php

// Active le debug WordPress
define('WP_DEBUG', true);

// Log dans wp-content/debug.log (ne pas afficher à l'écran)
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);

// Pour éviter des notices qui polluent certaines réponses AJAX/REST
@ini_set('display_errors', '0');

// Optionnel : utile si vous suspectez du cache d'objets persistant "fantôme"
define('WP_CACHE', true);

Ensuite, installez Query Monitor sur staging (ou en prod si vous acceptez un léger coût) : Query Monitor. Il vous donne les requêtes lentes, les hooks coûteux, et les scripts/styles chargés.

2) Mesurer LCP/INP/CLS en RUM (données réelles) avec un snippet JS léger

Les outils lab (Lighthouse) sont utiles, mais les régressions INP viennent souvent d’un script tiers déclenché sur un parcours utilisateur réel. Je préfère instrumenter au moins vos pages clés (home, article, landing).

Le snippet ci-dessous s’appuie sur la librairie officielle web-vitals (Google). Vous pouvez l’héberger localement pour éviter un tiers.

<?php
/**
 * Plugin minimal mu-plugin : wp-content/mu-plugins/bpcab-webvitals.php
 * Objectif : collecter LCP/INP/CLS et les envoyer à admin-ajax.php (ou REST).
 * Testé pour WordPress 6.9.4+ / PHP 8.1+
 */

add_action('wp_enqueue_scripts', function () {
	// Ne chargez pas ça pour les admins connectés si vous voulez des données "vraies"
	if (is_user_logged_in()) {
		return;
	}

	$handle = 'bpcab-webvitals';

	// Hébergez web-vitals localement (recommandé). Exemple : /assets/js/web-vitals.iife.min.js
	// Vous pouvez récupérer la version stable sur GitHub :
	// https://github.com/GoogleChrome/web-vitals
	wp_enqueue_script(
		'bpcab-webvitals-lib',
		get_stylesheet_directory_uri() . '/assets/js/web-vitals.iife.min.js',
		[],
		'4.0.0',
		true
	);

	wp_enqueue_script(
		$handle,
		get_stylesheet_directory_uri() . '/assets/js/bpcab-webvitals.js',
		['bpcab-webvitals-lib'],
		'1.0.0',
		true
	);

	wp_localize_script($handle, 'BPCAB_WEBVITALS', [
		'ajaxUrl' => admin_url('admin-ajax.php'),
		'nonce'   => wp_create_nonce('bpcab_webvitals'),
		// Échantillonnage : 0.1 = 10% des pages vues
		'sample'  => 0.1,
	]);
}, 20);

add_action('wp_ajax_nopriv_bpcab_webvitals', function () {
	// Sécurité : nonce obligatoire, sinon vous ouvrez une porte à du spam de logs
	check_ajax_referer('bpcab_webvitals', 'nonce');

	$metric = isset($_POST['metric']) ? sanitize_text_field(wp_unslash($_POST['metric'])) : '';
	$value  = isset($_POST['value']) ? floatval($_POST['value']) : null;
	$id     = isset($_POST['id']) ? sanitize_text_field(wp_unslash($_POST['id'])) : '';
	$url    = isset($_POST['url']) ? esc_url_raw(wp_unslash($_POST['url'])) : '';

	if (!$metric || $value === null) {
		wp_send_json_error(['message' => 'Payload incomplet'], 400);
	}

	// Stockage simple : log. En vrai, préférez une table dédiée ou un outil d'observabilité.
	// Attention RGPD : n'envoyez pas d'identifiants personnels.
	error_log(sprintf('[WEBVITALS] %s=%s id=%s url=%s', $metric, $value, $id, $url));

	wp_send_json_success(['ok' => true]);
});
// assets/js/bpcab-webvitals.js
(function () {
  // Échantillonnage simple
  if (!window.BPCAB_WEBVITALS || Math.random() > (BPCAB_WEBVITALS.sample || 1)) return;

  function send(metric) {
    try {
      var data = new FormData();
      data.append('action', 'bpcab_webvitals');
      data.append('nonce', BPCAB_WEBVITALS.nonce);
      data.append('metric', metric.name);
      data.append('value', metric.value);
      data.append('id', metric.id);
      data.append('url', location.href);

      // keepalive pour ne pas perdre l'event lors d'une navigation
      fetch(BPCAB_WEBVITALS.ajaxUrl, { method: 'POST', body: data, keepalive: true });
    } catch (e) {
      // Silence volontaire : pas de bruit côté client
    }
  }

  // webVitals est exposé par la version IIFE
  if (!window.webVitals) return;

  // LCP, INP, CLS
  webVitals.onLCP(send, { reportAllChanges: false });
  webVitals.onINP(send, { reportAllChanges: false });
  webVitals.onCLS(send, { reportAllChanges: false });
})();

Pourquoi ça aide : vous obtenez des valeurs p75 réelles (mobile inclus), et vous corrélez avec les pages/templates qui posent problème (souvent une landing Elementor/Divi spécifique).

3) Slow query log MySQL/MariaDB + WP-CLI pour isoler le TTFB

Quand LCP est mauvais malgré un front “propre”, le coupable est souvent le TTFB (génération HTML lente). Activez le slow query log côté DB (sur un serveur que vous contrôlez).

# Exemple (MySQL/MariaDB) - à adapter à votre environnement
# my.cnf / mysqld.cnf
slow_query_log=1
long_query_time=0.2
log_queries_not_using_indexes=0
slow_query_log_file=/var/log/mysql/mysql-slow.log

Et côté WordPress, WP-CLI est parfait pour repérer des problèmes structurels (transients, cron, options autoload). Référence officielle : WP-CLI commands.

# Top des options autoload (souvent un tueur de TTFB)
wp option list --autoload=on --fields=option_name,size_bytes --format=table | head -n 50

# Crons en retard (peuvent déclencher des tâches au mauvais moment)
wp cron event list --fields=hook,next_run --format=table | head -n 30

# Transients (si un plugin spamme la base)
wp transient list --format=table | head -n 50

Étape 1 : LCP — rendre l’image “hero” réellement prioritaire (preload + fetchpriority + tailles)

Le cas le plus fréquent en WordPress : l’élément LCP est une image “hero” ajoutée par le thème ou un builder, mais :

  • elle est en loading="lazy" (catastrophique pour LCP),
  • elle n’a pas de dimensions (le navigateur hésite sur le layout),
  • elle arrive après du CSS/JS bloquant,
  • elle n’est pas préchargée, donc la requête part trop tard.

Code AVANT (lent) : image hero lazy + sans priorité

<?php
// Exemple typique rendu par un module/builder
echo '<img src="' . esc_url($hero_url) . '" loading="lazy" alt="">';

Code APRÈS (optimisé) : preload + fetchpriority + pas de lazy + tailles

Objectif : si vous connaissez l’URL (ou l’ID) de l’image LCP, vous la préchargez dans le <head> et vous forcez une priorité haute sur la balise <img>. Sur mobile, ça fait souvent gagner 300 à 1200 ms selon le poids et le réseau.

1) Précharger l’image LCP dans le head

<?php
/**
 * functions.php (thème enfant) ou plugin
 * Précharge l'image "hero" sur la home et les pages d'articles.
 *
 * Note : adaptez la détection de l'image LCP à votre thème/builder.
 */
add_action('wp_head', function () {
	if (is_admin() || wp_is_json_request()) {
		return;
	}

	// Exemple : home = image d'en-tête du thème (custom header)
	$attachment_id = 0;

	if (is_front_page()) {
		$custom_header = get_custom_header();
		if (!empty($custom_header->attachment_id)) {
			$attachment_id = (int) $custom_header->attachment_id;
		}
	}

	// Exemple : article = image mise en avant
	if (is_singular('post')) {
		$thumb_id = get_post_thumbnail_id();
		if ($thumb_id) {
			$attachment_id = (int) $thumb_id;
		}
	}

	if (!$attachment_id) {
		return;
	}

	// Choisissez une taille cohérente avec votre CSS (évitez "full" si inutile)
	$src = wp_get_attachment_image_src($attachment_id, 'large');
	if (!$src || empty($src[0])) {
		return;
	}

	$href = esc_url($src[0]);

	// Preload image + fetchpriority (support moderne)
	echo '<link rel="preload" as="image" href="' . $href . '" fetchpriority="high">' . "n";
}, 1);

2) Forcer fetchpriority et éviter le lazy sur l’image LCP

WordPress gère déjà pas mal d’attributs d’images. Le problème, c’est que certains builders ajoutent du lazy partout, y compris sur le premier visuel. Corrigez au niveau des attributs générés.

<?php
/**
 * Ajuste les attributs des images WordPress.
 * - Si l'image correspond à la featured image sur un article, on la met en priorité haute
 * - On évite loading=lazy sur l'image LCP probable
 */
add_filter('wp_get_attachment_image_attributes', function (array $attr, $attachment, $size) {
	if (is_admin()) {
		return $attr;
	}

	// On cible un cas simple : image mise en avant en haut d'un article
	if (is_singular('post')) {
		$thumb_id = get_post_thumbnail_id();
		if ($thumb_id && (int) $attachment->ID === (int) $thumb_id) {
			// Priorité réseau
			$attr['fetchpriority'] = 'high';

			// Évite le lazy sur l'image LCP
			$attr['loading'] = 'eager';

			// Décodage async (souvent bénéfique)
			$attr['decoding'] = 'async';
		}
	}

	return $attr;
}, 10, 3);

3) Verrouiller les dimensions (ou aspect-ratio) pour accélérer le rendu

Si votre builder sort des images sans width/height, vous aurez du CLS et parfois un LCP retardé (le navigateur attend de comprendre la mise en page). WordPress ajoute généralement ces attributs, mais certains modules les suppriment.

Quand vous ne pouvez pas agir sur le HTML, vous pouvez stabiliser via CSS (moins parfait, mais efficace).

/* Exemple : hero en pleine largeur avec ratio stable */
.bpcab-hero img {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9; /* Adaptez à votre design réel */
  object-fit: cover;
}

Mesure d’impact (simple, reproductible)

Ajoutez un marqueur serveur pour mesurer le TTFB applicatif (temps PHP) et comparez avant/après. Ce n’est pas le LCP, mais ça évite de “tuner” à l’aveugle.

<?php
// mu-plugin : log du temps de génération côté PHP
add_action('template_redirect', function () {
	if (is_admin() || wp_is_json_request()) return;
	if (!defined('BPCAB_T0')) define('BPCAB_T0', microtime(true));
}, 0);

add_action('shutdown', function () {
	if (!defined('BPCAB_T0')) return;
	$ms = (microtime(true) - BPCAB_T0) * 1000;
	error_log(sprintf('[PERF] PHP render: %.1fms url=%s', $ms, (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '')));
}, 9999);

Dans mon expérience, si vous gagnez 150–300 ms de TTFB et que vous préchargez correctement l’image LCP, le LCP mobile suit presque mécaniquement.

Étape 2 : INP — couper les tâches longues JS (defer ciblé + découpage + events passifs)

INP est rarement “un seul gros bug”. C’est une accumulation : sliders, popups, analytics, tags, scripts de builder, plus un handler qui fait trop au clic.

Deux règles pratiques :

  • Tout ce qui n’est pas nécessaire au premier affichage doit être retardé (defer / after interaction / after idle).
  • Quand une interaction arrive, votre handler doit rendre la main vite (pas de boucle, pas de layout thrash, pas de requête synchrone).

Diagnostic INP : détecter les “long tasks” côté navigateur

Ce snippet journalise les tâches longues (main thread bloqué > 50 ms). Sur une page lente, vous verrez souvent des tâches > 200–800 ms liées à un bundle.

// À inclure via wp_enqueue_script (footer)
(function () {
  if (!('PerformanceObserver' in window)) return;

  try {
    var obs = new PerformanceObserver(function (list) {
      list.getEntries().forEach(function (entry) {
        // entry.duration = durée de la tâche longue
        if (entry.duration >= 100) {
          console.log('[LongTask]', Math.round(entry.duration) + 'ms', entry);
        }
      });
    });
    obs.observe({ entryTypes: ['longtask'] });
  } catch (e) {}
})();

Code AVANT (lent) : tout en “non-defer” + handlers lourds

<?php
// Exemple anti-pattern : charger un script lourd partout, en tête, sans defer
add_action('wp_enqueue_scripts', function () {
	wp_enqueue_script('mon-slider', get_stylesheet_directory_uri() . '/assets/js/slider.js', [], '1.0', false);
});

Code APRÈS (optimisé) : defer ciblé + chargement conditionnel + “after interaction”

1) Charger uniquement là où c’est nécessaire

Sur WordPress, le gain le plus propre est souvent : “ne pas charger”. Divi/Elementor/Avada ont tendance à charger beaucoup globalement. Vous pouvez déjà limiter vos scripts custom.

<?php
add_action('wp_enqueue_scripts', function () {
	// Exemple : slider uniquement sur une landing
	if (!is_page('landing-offre')) {
		return;
	}

	wp_enqueue_script(
		'mon-slider',
		get_stylesheet_directory_uri() . '/assets/js/slider.js',
		[],
		'1.1.0',
		true // footer
	);
}, 20);

2) Ajouter defer (sans casser les dépendances)

Beaucoup de devs ajoutent async partout et cassent l’ordre d’exécution (surtout avec jQuery). Pour l’INP, defer est plus sûr : l’ordre est conservé.

<?php
/**
 * Ajoute defer à une liste blanche de scripts.
 * Attention : ne mettez pas defer sur un script inline critique qui dépend d'un chargement immédiat.
 */
add_filter('script_loader_tag', function ($tag, $handle, $src) {
	$defer_handles = [
		'mon-slider',
		// Ajoutez ici vos handles (pas ceux du core au hasard)
	];

	if (in_array($handle, $defer_handles, true)) {
		// Injecte defer si absent
		if (false === strpos($tag, ' defer')) {
			$tag = str_replace('<script ', '<script defer ', $tag);
		}
	}

	return $tag;
}, 10, 3);

3) Retarder l’initialisation jusqu’à la première interaction

Très efficace sur mobile : vous chargez le script (ou vous l’initialisez) seulement quand l’utilisateur interagit (scroll, pointerdown, keydown). Ça évite de bloquer le main thread au chargement.

// assets/js/slider-bootstrap.js
(function () {
  var started = false;

  function start() {
    if (started) return;
    started = true;

    // Exemple : import dynamique si vous utilisez des modules.
    // Sinon, vous pouvez simplement initialiser votre slider ici.
    if (window.initMonSlider) {
      window.initMonSlider();
    }
  }

  // Première interaction utilisateur
  ['pointerdown', 'keydown', 'touchstart', 'wheel'].forEach(function (evt) {
    window.addEventListener(evt, start, { passive: true, once: true });
  });

  // Fallback : après 3s si aucune interaction (évite que la page reste "cassée")
  setTimeout(start, 3000);
})();

En pratique, je couple ça avec un découpage : le script “bootstrap” est minuscule, et le gros bundle est chargé après interaction.

4) Events passifs + éviter le layout thrashing

Un classique INP : un handler de scroll/touch non passif, plus des lectures/écritures DOM entremêlées.

// AVANT : peut bloquer le scroll et déclencher du jank
window.addEventListener('touchmove', function () {
  var h = document.body.offsetHeight; // lecture layout
  document.body.style.paddingTop = (h % 10) + 'px'; // écriture layout
});

// APRÈS : listener passif + batch avec requestAnimationFrame
(function () {
  var scheduled = false;

  window.addEventListener('touchmove', function () {
    if (scheduled) return;
    scheduled = true;

    requestAnimationFrame(function () {
      scheduled = false;
      // Faites le minimum ici, et évitez les lectures/écritures inutiles
    });
  }, { passive: true });
})();

Compatibilité Divi 5 / Elementor / Avada

Sur des sites builder, l’INP est souvent plombé par des scripts globaux (animations, popups, formulaires). Deux approches sûres :

  • ajouter defer uniquement à vos scripts et à quelques scripts tiers identifiés via Query Monitor (pas en aveugle),
  • retarder l’initialisation de vos widgets/modules custom (Divi 5 module, Elementor widget, Avada element) jusqu’à interaction.

Étape 3 : CLS — réserver l’espace (dimensions, placeholders, polices, embeds)

Le CLS sur WordPress vient rarement du core. Il vient des contenus : images insérées au mauvais format, iframes (YouTube, maps), pubs/affiliation, et polices web.

1) Images : forcer width/height (ou corriger les HTML “sales”)

WordPress ajoute width et height quand il génère l’image via ses fonctions. Le CLS apparaît quand :

  • un builder injecte <img> sans dimensions,
  • un shortcode sort une image brute,
  • un lazy-loader remplace l’image par un placeholder mal dimensionné.

Si vous avez du contenu HTML en base (éditeur) avec des <img> sans dimensions, vous pouvez tenter un correctif via the_content. Attention : c’est fragile, testez sur staging.

<?php
/**
 * Ajoute width/height aux images du contenu quand possible.
 * Risque : certains thèmes/plugins font du HTML non standard.
 * Testez et ajoutez des garde-fous.
 */
add_filter('the_content', function ($content) {
	if (is_admin() || !is_singular()) {
		return $content;
	}

	// Petite optimisation : ne parse pas si pas d'img
	if (false === stripos($content, '<img')) {
		return $content;
	}

	$dom = new DOMDocument();

	// Évite des warnings sur HTML fragmentaire
	libxml_use_internal_errors(true);
	$dom->loadHTML('<meta charset="utf-8">' . $content);
	libxml_clear_errors();

	$imgs = $dom->getElementsByTagName('img');
	foreach ($imgs as $img) {
		/** @var DOMElement $img */
		if ($img->hasAttribute('width') && $img->hasAttribute('height')) {
			continue;
		}

		$src = $img->getAttribute('src');
		if (!$src) continue;

		// Tente de mapper l'URL vers un attachment (pas garanti)
		$attachment_id = attachment_url_to_postid($src);
		if (!$attachment_id) continue;

		$meta = wp_get_attachment_metadata($attachment_id);
		if (empty($meta['width']) || empty($meta['height'])) continue;

		$img->setAttribute('width', (string) (int) $meta['width']);
		$img->setAttribute('height', (string) (int) $meta['height']);
	}

	// Récupère le body sans le wrapper ajouté par DOMDocument
	$body = $dom->getElementsByTagName('body')->item(0);
	if (!$body) return $content;

	$out = '';
	foreach ($body->childNodes as $child) {
		$out .= $dom->saveHTML($child);
	}

	return $out ?: $content;
}, 20);

2) Iframes/embeds : placeholder avec ratio fixe

YouTube et consorts provoquent du CLS si l’iframe arrive sans taille stable. Fixez via CSS sur vos wrappers.

/* Wrapper responsive stable pour iframes */
.bpcab-embed {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: #000; /* placeholder visuel */
  overflow: hidden;
}

.bpcab-embed iframe {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

Si votre contenu Gutenberg/builder ne met pas de wrapper, vous pouvez en injecter via filtre, mais c’est spécifique à chaque site. Sur Elementor/Divi, je préfère ajouter une classe au module vidéo et appliquer le CSS.

3) Polices : preload + fallback métrique pour limiter le “swap”

Le CLS peut venir d’un changement de métriques entre police de fallback et police finale. Deux actions :

  • précharger la police principale (si elle est locale),
  • choisir un fallback avec métriques proches (ou utiliser size-adjust si vous maîtrisez).
<?php
// Précharge une police WOFF2 locale (à adapter)
// Attention : ne préchargez pas 5 polices, sinon vous dégradez LCP/TTFB réseau.
add_action('wp_head', function () {
	if (is_admin()) return;

	$font_url = get_stylesheet_directory_uri() . '/assets/fonts/Inter-roman.var.woff2';
	echo '<link rel="preload" href="' . esc_url($font_url) . '" as="font" type="font/woff2" crossorigin>' . "n";
}, 2);
/* Fallback propre : évite un saut massif si la webfont arrive tard */
body {
  font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}

/* Si vous utilisez @font-face, gardez font-display: swap (ou optional selon stratégie) */
@font-face {
  font-family: "Inter";
  src: url("/wp-content/themes/votre-theme/assets/fonts/Inter-roman.var.woff2") format("woff2");
  font-display: swap;
}

Étape 4 : TTFB et goulots côté serveur (requêtes, transients, cache objet)

Core Web Vitals ne “mesure pas” directement le TTFB, mais un TTFB élevé retarde tout, donc LCP. Sur WordPress, j’ai souvent trouvé :

  • des options autoload énormes (plugins qui stockent trop),
  • des requêtes répétées dans un hook appelé 50 fois,
  • un cache objet absent (Redis/Memcached) alors que le site a beaucoup de pages dynamiques.

Code AVANT (lent) : requête répétée dans un hook fréquent

<?php
// Anti-pattern : requête SQL à chaque appel d'un shortcode
add_shortcode('bpcab_top_posts', function () {
	global $wpdb;
	$rows = $wpdb->get_results("SELECT ID FROM {$wpdb->posts} WHERE post_type='post' AND post_status='publish' ORDER BY comment_count DESC LIMIT 5");
	// ...
	return '...';
});

Code APRÈS (optimisé) : cache transient + invalidation

<?php
add_shortcode('bpcab_top_posts', function () {
	$cache_key = 'bpcab_top_posts_v1';

	$ids = get_transient($cache_key);
	if (!is_array($ids)) {
		$q = new WP_Query([
			'post_type'              => 'post',
			'post_status'            => 'publish',
			'posts_per_page'         => 5,
			'orderby'                => 'comment_count',
			'no_found_rows'          => true,
			'ignore_sticky_posts'    => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'fields'                 => 'ids',
		]);

		$ids = $q->posts;

		// Cache 10 minutes (à ajuster)
		set_transient($cache_key, $ids, 10 * MINUTE_IN_SECONDS);
	}

	if (empty($ids)) {
		return '';
	}

	$out = '<ul class="bpcab-top-posts">';
	foreach ($ids as $id) {
		$out .= '<li><a href="' . esc_url(get_permalink($id)) . '">' . esc_html(get_the_title($id)) . '</a></li>';
	}
	$out .= '</ul>';

	return $out;
});

// Invalidation simple lors de la publication/édition
add_action('save_post_post', function () {
	delete_transient('bpcab_top_posts_v1');
}, 10, 0);

Pourquoi c’est plus rapide : vous éliminez une requête (et ses coûts de cache/meta) sur chaque page vue. Sur un site avec trafic, ça fait baisser le temps PHP et stabilise le LCP.

Cache objet persistant : quand ça vaut le coup

Si votre site a beaucoup d’utilisateurs connectés, de pages dynamiques, ou un builder lourd, un cache page ne suffit pas. Un cache objet persistant (Redis/Memcached) réduit la latence des requêtes répétées.

Côté WordPress, le principe est d’installer un drop-in object-cache.php (souvent via plugin Redis Object Cache). Référence : Transients API et WP_Object_Cache.

Configuration serveur

Vous pouvez avoir un front parfait et perdre sur LCP à cause d’une compression absente ou d’un cache mal réglé. Voici des réglages “copier-coller” courants. Adaptez à votre hébergement (Apache vs Nginx, etc.).

.htaccess (Apache) : compression + cache statique

# .htaccess - exemples
# Attention : testez, certains hébergeurs mutualisés ont des modules désactivés.

<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/plain text/css application/javascript application/json image/svg+xml
</IfModule>

<IfModule mod_expires.c>
  ExpiresActive On
  ExpiresByType text/css "access plus 30 days"
  ExpiresByType application/javascript "access plus 30 days"
  ExpiresByType image/webp "access plus 365 days"
  ExpiresByType image/avif "access plus 365 days"
  ExpiresByType image/jpeg "access plus 365 days"
  ExpiresByType image/png "access plus 365 days"
  ExpiresByType font/woff2 "access plus 365 days"
</IfModule>

# Cache-Control (si mod_headers est dispo)
<IfModule mod_headers.c>
  <FilesMatch ".(css|js|woff2|png|jpg|jpeg|webp|avif|svg)$">
    Header set Cache-Control "public, max-age=2592000, immutable"
  </FilesMatch>
</IfModule>

php.ini (ou ini_set) : OPcache pour réduire le TTFB

OPcache est souvent déjà activé, mais mal dimensionné. Référence : PHP OPcache.

; php.ini - valeurs indicatives (selon RAM et taille du site)
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=2

wp-config.php : limiter WP-Cron en trafic réel

WP-Cron déclenché au chargement peut créer des pics et dégrader l’INP/LCP sur mobile (car le serveur est occupé). Sur un site avec trafic, désactivez WP-Cron et passez par un cron système.

<?php
// wp-config.php
define('DISABLE_WP_CRON', true);
# Cron système (toutes les 5 minutes)
*/5 * * * * curl -fsS https://votre-site.tld/wp-cron.php?doing_wp_cron > /dev/null

Vérification des résultats

1) Vérifier que la ressource LCP part plus tôt (préload effectif)

Sans capture d’écran, vous pouvez quand même vérifier via Performance API si l’image a été préchargée et quand elle a démarré.

// À exécuter dans la console (ou à logguer temporairement)
(function () {
  var entries = performance.getEntriesByType('resource');
  var hero = entries.filter(function (e) { return e.initiatorType === 'img' && /hero|header|featured/i.test(e.name); });
  console.table(hero.map(function (e) {
    return { name: e.name.split('?')[0], startTime: Math.round(e.startTime), duration: Math.round(e.duration) };
  }));
})();

2) Comparer les web-vitals p75 avant/après (vos logs)

Avec le snippet RUM, calculez un p75 (même à la main au début). Attendez un volume minimal et segmentez par template (home vs article vs landing builder).

  • LCP : viser < 2,5 s (mobile), idéalement < 2,0 s sur pages clés.
  • INP : viser < 200 ms, tolérable < 500 ms.
  • CLS : viser < 0,1.

3) Vérifier l’absence de régressions (cache, JS cassé)

Après avoir ajouté defer, testez :

  • menus (mobile),
  • formulaires,
  • popups (si vous en avez),
  • pages builder (Divi/Elementor/Avada) avec modules dynamiques.

Si les performances ne s’améliorent pas

Quand LCP/INP/CLS ne bougent pas, le problème vient généralement d’un de ces points :

  • Vous n’avez pas optimisé le bon élément LCP : ce n’est pas l’image hero mais un gros bloc texte ou un slider.
  • Un script tiers domine l’INP : tag manager, A/B testing, chat, ads.
  • Le CLS vient d’un élément “hors contenu” : bandeau cookie, barre promo, injection tardive.
  • Le cache masque les changements : cache page/CDN garde l’ancien HTML, ou le navigateur garde l’ancien JS/CSS.

Checklist debug (code/technique)

  • Ajoutez temporairement un log des scripts/styles chargés sur une page lente (via wp_print_scripts / wp_print_styles).
  • Désactivez un script tiers à la fois (feature flag) et mesurez INP.
  • Mesurez le temps PHP (snippet shutdown) et corrélez avec Query Monitor.
<?php
// Log la liste des scripts en front (utile quand vous suspectez un builder/plugin)
add_action('wp_print_scripts', function () {
	if (is_admin() || is_user_logged_in()) return;

	global $wp_scripts;
	if (empty($wp_scripts->queue)) return;

	error_log('[SCRIPTS] ' . implode(', ', $wp_scripts->queue));
}, 999);

Pièges et erreurs courantes

Erreurs que je vois souvent (et qui font perdre des heures)

  • Copier un snippet dans le mauvais fichier (ex. dans header.php au lieu de functions.php) et casser le site.
  • Oublier un point-virgule dans wp-config.php (écran blanc, et vos tests s’arrêtent).
  • Mettre defer sur un script dont dépend un inline script dans le head (menu cassé).
  • Optimiser en prod sans sauvegarde ni staging (et devoir rollback dans l’urgence).
  • Tester sans vider le cache page/CDN (vous mesurez l’ancien code).
  • Utiliser un vieux snippet “2019” de lazy-load qui force loading="lazy" partout (LCP détruit).

Tableau de diagnostic (symptôme → cause → mesure → solution)

Symptôme Cause probable Vérification Solution
LCP > 4s sur mobile Image hero non priorisée, CSS bloquant, TTFB élevé RUM LCP + logs temps PHP + Query Monitor Preload + fetchpriority + supprimer lazy sur hero + réduire TTFB (cache/transients)
INP > 500ms sur pages builder Tâches longues JS, scripts tiers, handlers lourds PerformanceObserver longtask + désactivation progressive scripts Defer ciblé + init après interaction + découper handlers + limiter scripts globaux
CLS > 0.25 Images/iframes sans dimensions, bandeau cookie tardif, webfonts RUM CLS + audit HTML (img sans width/height) width/height ou aspect-ratio + placeholders embeds + preload fonts + fallback
Scores variables selon les tests Cache/CDN incohérent, variations de contenu, scripts A/B Comparer HTML servi + headers cache + logs CDN Stabiliser cache headers, réduire variations, isoler scripts d’expérimentation
Optimisations “sans effet” Vous n’avez pas ciblé l’élément LCP réel web-vitals + identification LCP element (lab) Prioriser la vraie ressource LCP (pas celle supposée)

Conseils de maintenance

  • Gardez un RUM minimal sur 2–3 templates critiques. Les régressions arrivent après une mise à jour de builder ou un ajout de script marketing.
  • Versionnez vos assets (paramètre de version dans wp_enqueue_script) pour éviter les caches “mixtes”.
  • Évitez les snippets magiques globaux (genre “defer tous les scripts”). Faites une liste blanche, mesurez, puis élargissez.
  • Surveillez les options autoload : un plugin peut gonfler wp_options et ruiner le TTFB sans que vous ne touchiez au front.
  • Après update Divi/Elementor/Avada, refaites un échantillon de mesures INP. Ces plugins changent souvent leur pipeline JS.

Ressources

FAQ

Comment trouver “l’élément LCP” exact sur une page WordPress ?

En lab (Lighthouse / DevTools), vous pouvez identifier l’élément LCP. En RUM, vous mesurez la valeur, mais pas toujours l’élément. En pratique, commencez par le suspect n°1 (hero image/titre), puis validez en comparant les timings de requêtes (Performance API) et vos changements (preload/fetchpriority).

Est-ce que je peux mettre fetchpriority=”high” sur plusieurs images ?

Vous pouvez, mais vous ne devriez pas. Si tout est “prioritaire”, rien ne l’est. Gardez high pour 1 ressource LCP (et éventuellement un logo critique), sinon vous risquez de retarder d’autres ressources utiles.

Pourquoi mon LCP se dégrade après avoir activé un lazy-load plugin ?

Parce que beaucoup de plugins lazy-load appliquent loading="lazy" au premier visuel. Sur mobile, la requête de l’image LCP part trop tard. Corrigez via wp_get_attachment_image_attributes (mettre loading="eager" sur l’image LCP) ou désactivez le lazy sur le “above the fold”.

Defer sur jQuery : bonne idée ?

Souvent non, surtout si vous avez des scripts inline dans le head qui supposent jQuery disponible immédiatement. Si vous voulez améliorer l’INP, commencez par defer vos scripts non critiques et chargez jQuery uniquement sur les pages qui en ont besoin (si possible). Testez chaque page interactive.

INP est mauvais uniquement sur mobile : pourquoi ?

CPU plus lent + main thread plus fragile. Un handler “acceptable” sur desktop devient une tâche longue sur mobile. Retarder l’initialisation, découper les handlers et réduire le JS global ont généralement plus d’impact que la minification.

Comment réduire le CLS causé par un bandeau cookie ?

Réservez l’espace dès le départ (placeholder fixe), ou superposez le bandeau (position fixed) sans pousser le contenu. Si le bandeau injecte du DOM tardivement et décale tout, votre CLS va rester élevé même si les images sont parfaites.

Est-ce que le cache page règle LCP/INP/CLS ?

Il aide surtout le TTFB (donc LCP), mais il ne règle pas INP/CLS si votre JS est lourd ou si votre layout bouge. Traitez cache + front ensemble.

Query Monitor suffit pour diagnostiquer les Core Web Vitals ?

Non. Query Monitor est excellent pour le serveur (requêtes, hooks, scripts), mais LCP/INP/CLS dépendent du navigateur. Combinez Query Monitor + RUM web-vitals + un peu de PerformanceObserver pour les long tasks.

Je suis sur Divi 5 / Elementor / Avada : où mettre ce code ?

Le plus robuste : un mu-plugin (dans wp-content/mu-plugins/) ou un plugin maison. En thème enfant, ça marche aussi, mais vous risquez de perdre des modifications si vous changez de thème. Évitez de coller du PHP dans un module “Code” du builder.

Quels seuils viser en 2026 ?

Restez aligné sur les seuils Web Vitals actuels (Good/Needs Improvement/Poor) et suivez surtout votre p75 mobile par template. Les seuils peuvent évoluer, mais la méthode (prioriser LCP, réduire tâches longues, stabiliser layout) reste valable.

Je n’ai aucun gain après preload : pourquoi ?

Souvent parce que :

  • vous préchargez la mauvaise URL (srcset/size différent),
  • le preload arrive trop tard (hook trop tardif),
  • le cache/CDN sert un HTML différent,
  • l’élément LCP n’est pas l’image mais un bloc texte rendu tard (CSS, font).

Validez avec performance.getEntriesByType('resource') et assurez-vous que la requête démarre plus tôt.