Si vous avez déjà vu une page WordPress “rapide” en Wi‑Fi devenir pénible en 4G, le problème n’est pas seulement le poids des images : c’est la répétition des mêmes téléchargements et l’absence de stratégie offline. Un Service Worker bien écrit peut transformer votre front en “offline-first” pour les assets (CSS/JS/images/polices), réduire les requêtes réseau, et stabiliser les Core Web Vitals sur mobile.
Le problème de performance
Le symptôme typique : un LCP qui varie énormément selon la qualité réseau, un FCP correct en desktop mais instable en mobile, et des “stalls” quand le navigateur retélécharge des assets déjà vus. J’ai souvent croisé ça sur des sites avec Divi/Elementor/Avada où le nombre de fichiers (CSS fractionnés, JS de widgets, polices) explose, et où les visiteurs reviennent plusieurs fois sur le site.
Le coût concret :
- SEO : les Core Web Vitals (notamment LCP/INP) se dégradent sur mobile et sur réseaux instables.
- Taux de rebond : au second chargement, vous pourriez être instantané… mais vous ne l’êtes pas si rien n’est mis en cache côté navigateur.
- Expérience offline : un simple “pas de réseau” devient un écran blanc, alors que vos assets pourraient être servis depuis le cache.
À la fin, vous aurez :
- un Service Worker “production-grade” (WordPress 6.9.4 + PHP 8.1+) qui met en cache les assets et gère les mises à jour,
- un versioning fiable (cache busting) lié à vos déploiements,
- des stratégies par type de ressource (stale-while-revalidate, cache-first, network-first),
- des garde-fous pour ne pas casser wp-admin, l’éditeur, Divi/Elementor/Avada.
Résumé rapide
- Servez un service-worker.js à la racine, scope “/”, et enregistrez-le via un petit plugin MU ou un plugin standard.
- Cachez les assets statiques (CSS/JS/images/fonts) en stale-while-revalidate ou cache-first selon le type.
- N’essayez pas de rendre tout WordPress offline : commencez par les assets + une page offline.
- Gérez l’invalidation via un SW_VERSION dérivé du déploiement (ex: hash Git, timestamp de build) + purge contrôlée.
- Mesurez : logging SW en dev, DevTools + WebPageTest/Lighthouse, et contrôlez le taux de hit cache.
Diagnostic avec du code
1) Activer un minimum de télémétrie côté WordPress (sans bruit en prod)
Sur un site lent, je commence par vérifier si le problème est “réseau/asset” (notre sujet) ou “serveur/DB”. Activez les logs WordPress proprement.
<?php
// wp-config.php
// Logs PHP/WordPress (évitez WP_DEBUG_DISPLAY en prod)
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
// Optionnel : log des erreurs fatales dans un fichier dédié (selon hébergeur)
@ini_set('log_errors', '1');
// Attention : ne laissez pas ça activé sur un site à fort trafic sans rotation de logs.
2) Mesurer la contribution “assets” côté front
Ajoutez un mini “performance mark” pour mesurer le temps de chargement ressenti (TTI approximatif) et surtout pour comparer avant/après l’introduction du Service Worker.
// À mettre dans un fichier JS chargé sur le front (ou injecté via wp_add_inline_script)
(function () {
if (!('performance' in window)) return;
// Marqueur simple : DOMContentLoaded
document.addEventListener('DOMContentLoaded', function () {
performance.mark('wp_domcontentloaded');
});
// Après load : on lit quelques timings utiles
window.addEventListener('load', function () {
performance.mark('wp_window_load');
var nav = performance.getEntriesByType('navigation')[0];
if (!nav) return;
// À adapter : envoyez ces données à votre endpoint si besoin
console.log('[perf] TTFB(ms):', Math.round(nav.responseStart));
console.log('[perf] DOMContentLoaded(ms):', Math.round(nav.domContentLoadedEventEnd));
console.log('[perf] Load(ms):', Math.round(nav.loadEventEnd));
});
})();
3) Query Monitor + slow query log : éliminer le faux coupable
Un Service Worker ne corrigera jamais un TTFB à 2 secondes dû à MySQL. Vérifiez rapidement.
- Installez Query Monitor (plugin) et vérifiez les requêtes lentes : Query Monitor.
- Si vous avez accès au serveur, activez le slow query log MySQL (hors scope WordPress) et corrigez les requêtes.
4) WP-CLI : état du site et inventaire des scripts
WP-CLI aide à repérer les thèmes/plugins lourds et à auditer les versions.
# Versions
wp core version
wp plugin list --status=active
wp theme list --status=active
# Vérifier si vous avez des MU-plugins (souvent utilisés pour injecter du code perf)
wp mu-plugin list
# Export des options autoload (souvent corrélé à la lenteur admin/TTFB)
wp option list --autoload=on --fields=option_name,size_bytes --format=table
Si votre TTFB est déjà bon (ex: < 300–500ms) mais que le chargement “ressenti” varie, vous êtes dans le bon cas d’usage : optimiser le cache navigateur via Service Worker.
Étape 1 : Service Worker minimal pour mettre en cache les assets (offline-first)
Architecture recommandée (WordPress 6.9.4)
Objectif : servir /service-worker.js à la racine du domaine (scope “/”). Le plus robuste en WordPress consiste à :
- déposer un fichier service-worker.js dans wp-content/mu-plugins/sw/ (ou dans un plugin standard),
- le servir via un endpoint WordPress qui répond sur /service-worker.js avec les bons headers,
- enregistrer le SW via un petit script front, et éviter wp-admin / preview.
Pourquoi un endpoint plutôt qu’un fichier statique ? Parce que vous voulez injecter une version (build id), et parfois une liste de ressources “pré-cache” calculée côté PHP. Si vous avez un pipeline de build (Vite/Webpack) qui produit un SW statique versionné, c’est encore mieux, mais ici on reste 100% WordPress.
MU-plugin : router /service-worker.js + enregistrer le SW
Créez wp-content/mu-plugins/wp-perf-service-worker.php. Les MU-plugins sont chargés avant les plugins classiques, pratique pour ce type de feature.
<?php
/**
* Plugin Name: Perf - Service Worker (assets offline-first)
* Description: Sert /service-worker.js et enregistre le SW côté front. WordPress 6.9.4+, PHP 8.1+.
*/
if (!defined('ABSPATH')) {
exit;
}
final class WP_Perf_Service_Worker {
private const ROUTE = 'service-worker.js';
/**
* Version de cache (à changer à chaque déploiement).
* Astuce : remplacez par un hash de build (CI) via une constante dans wp-config.php.
*/
private string $version;
public function __construct() {
$this->version = defined('WP_SW_VERSION') ? (string) WP_SW_VERSION : (string) get_option('wp_sw_version', 'dev-1');
add_action('init', [$this, 'add_rewrite_rule']);
add_filter('query_vars', [$this, 'add_query_var']);
add_action('template_redirect', [$this, 'maybe_serve_service_worker']);
add_action('wp_enqueue_scripts', [$this, 'enqueue_register_script'], 20);
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_notice_script'], 20);
// À activer une fois (ou via activation hook d’un plugin classique)
add_action('init', [$this, 'maybe_flush_rewrite_rules'], 1);
}
public function add_rewrite_rule(): void {
add_rewrite_rule('^' . preg_quote(self::ROUTE, '/') . '$', 'index.php?wp_sw=1', 'top');
}
public function add_query_var(array $vars): array {
$vars[] = 'wp_sw';
return $vars;
}
public function maybe_serve_service_worker(): void {
if ((int) get_query_var('wp_sw') !== 1) {
return;
}
// Sécurité : ne servez pas le SW sur un site non HTTPS (sauf localhost).
$is_localhost = in_array($_SERVER['HTTP_HOST'] ?? '', ['localhost', '127.0.0.1'], true);
if (!is_ssl() && !$is_localhost) {
status_header(400);
header('Content-Type: text/plain; charset=UTF-8');
echo "Service Worker requires HTTPS.n";
exit;
}
// Headers : JS + cache court (le SW gère son propre cycle, mais évitez un cache trop long côté HTTP)
header('Content-Type: application/javascript; charset=UTF-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
// Petit durcissement
header("X-Content-Type-Options: nosniff");
echo $this->render_service_worker_js();
exit;
}
private function render_service_worker_js(): string {
$version = esc_js($this->version);
// Page offline simple (optionnel). Vous pouvez la créer en dur ou pointer vers une URL.
$offline_url = esc_js(home_url('/offline/'));
// Note : on ne “pré-cache” pas toute la homepage (HTML) ici, uniquement une offline page + assets.
// Le precache agressif d’HTML sur WordPress est un nid à bugs (cookies, cache utilisateur, A/B, etc.).
return "(function(){n" .
"'use strict';n" .
"const SW_VERSION = '{$version}';n" .
"const CACHE_ASSETS = 'wp-assets-' + SW_VERSION;n" .
"const CACHE_PAGES = 'wp-pages-' + SW_VERSION;n" .
"const OFFLINE_URL = '{$offline_url}';n" .
"n" .
"self.addEventListener('install', (event) => {n" .
" event.waitUntil((async () => {n" .
" const cache = await caches.open(CACHE_PAGES);n" .
" // Offline fallback : si l’URL n’existe pas, la requête échouera et ce n’est pas bloquant.n" .
" try { await cache.add(new Request(OFFLINE_URL, {cache: 'reload'})); } catch (e) {}n" .
" self.skipWaiting();n" .
" })());n" .
"});n" .
"n" .
"self.addEventListener('activate', (event) => {n" .
" event.waitUntil((async () => {n" .
" const keys = await caches.keys();n" .
" await Promise.all(keys.map((key) => {n" .
" if (!key.endsWith(SW_VERSION) && (key.startsWith('wp-assets-') || key.startsWith('wp-pages-'))) {n" .
" return caches.delete(key);n" .
" }n" .
" return Promise.resolve();n" .
" }));n" .
" await self.clients.claim();n" .
" })());n" .
"});n" .
"n" .
"function isAssetRequest(request) {n" .
" const url = new URL(request.url);n" .
" // Même origine uniquement : évite de capturer des CDN tiers non contrôlés.n" .
" if (url.origin !== self.location.origin) return false;n" .
" return url.pathname.match(/\.(css|js|png|jpg|jpeg|gif|webp|avif|svg|woff2|woff|ttf|eot)$/i);n" .
"}n" .
"n" .
"async function staleWhileRevalidate(request) {n" .
" const cache = await caches.open(CACHE_ASSETS);n" .
" const cached = await cache.match(request);n" .
" const fetchPromise = fetch(request).then((response) => {n" .
" // On ne met en cache que les réponses OK et de type basic (same-origin).n" .
" if (response && response.ok && response.type === 'basic') {n" .
" cache.put(request, response.clone());n" .
" }n" .
" return response;n" .
" }).catch(() => null);n" .
" return cached || (await fetchPromise) || new Response('', {status: 504, statusText: 'Gateway Timeout'});n" .
"}n" .
"n" .
"async function networkFirstForPages(request) {n" .
" const cache = await caches.open(CACHE_PAGES);n" .
" try {n" .
" const response = await fetch(request);n" .
" if (response && response.ok && response.type === 'basic') {n" .
" cache.put(request, response.clone());n" .
" }n" .
" return response;n" .
" } catch (e) {n" .
" const cached = await cache.match(request);n" .
" return cached || (await cache.match(OFFLINE_URL)) || new Response('Offline', {status: 503});n" .
" }n" .
"}n" .
"n" .
"self.addEventListener('fetch', (event) => {n" .
" const request = event.request;n" .
"n" .
" // Ne jamais intercepter : POST, admin, preview, wp-login, REST non-idempotent.n" .
" if (request.method !== 'GET') return;n" .
" const url = new URL(request.url);n" .
" if (url.pathname.startsWith('/wp-admin/') || url.pathname.startsWith('/wp-login.php')) return;n" .
" if (url.searchParams.has('preview') || url.searchParams.has('elementor-preview')) return;n" .
"n" .
" if (isAssetRequest(request)) {n" .
" event.respondWith(staleWhileRevalidate(request));n" .
" return;n" .
" }n" .
"n" .
" // HTML navigation : network-first + fallback offlinen" .
" if (request.mode === 'navigate') {n" .
" event.respondWith(networkFirstForPages(request));n" .
" return;n" .
" }n" .
"});n" .
"n" .
"// Canal de commande (optionnel) : permet de forcer skipWaiting depuis le front.n" .
"self.addEventListener('message', (event) => {n" .
" if (event.data && event.data.type === 'SKIP_WAITING') {n" .
" self.skipWaiting();n" .
" }n" .
"});n" .
"n" .
"})();n";
}
public function enqueue_register_script(): void {
if (is_admin()) {
return;
}
// Évitez d’enregistrer le SW sur des environnements qui ne doivent pas être cachés (staging).
if (defined('WP_ENVIRONMENT_TYPE') && WP_ENVIRONMENT_TYPE !== 'production') {
// À vous de décider : souvent je l’active aussi en staging, mais avec une version différente.
}
// Ne pas charger dans l’iframe Customizer / prévisualisation
if (is_customize_preview()) {
return;
}
$handle = 'wp-sw-register';
wp_register_script(
$handle,
plugins_url('sw-register.js', __FILE__), // Ce fichier n’existe pas encore : créez-le à côté du MU-plugin si vous le convertissez en plugin classique.
[],
$this->version,
['in_footer' => true]
);
// Comme MU-plugin, plugins_url() ne pointe pas vers mu-plugins correctement sur certains setups.
// Pattern robuste : injecter inline JS au lieu de dépendre d’un fichier.
wp_enqueue_script($handle);
wp_add_inline_script($handle, $this->get_register_inline_js(), 'after');
}
private function get_register_inline_js(): string {
$sw_url = esc_js(home_url('/' . self::ROUTE));
$version = esc_js($this->version);
return "(function(){n" .
"'use strict';n" .
"if (!('serviceWorker' in navigator)) return;n" .
"// Évitez d’enregistrer sur wp-admin par erreurn" .
"if (location.pathname.indexOf('/wp-admin/') === 0) return;n" .
"n" .
"window.addEventListener('load', function(){n" .
" navigator.serviceWorker.register('{$sw_url}', { scope: '/' })n" .
" .then(function(reg){n" .
" // Déclenche une mise à jour si la version changen" .
" reg.update();n" .
" console.log('[sw] registered v={$version}');n" .
" })n" .
" .catch(function(err){n" .
" console.warn('[sw] register failed', err);n" .
" });n" .
"});n" .
"})();n";
}
public function enqueue_admin_notice_script(): void {
// Astuce : si vous avez des soucis en dev, rappelez-vous que le SW peut “coller”.
// Ici on ne fait rien, mais je laisse le hook en place si vous voulez ajouter un bouton de reset.
}
private function maybe_flush_rewrite_rules(): void {
// Erreur fréquente : oublier de régénérer les permaliens => /service-worker.js renvoie 404.
// En MU-plugin, pas d’activation hook. On fait un flush contrôlé 1 fois.
$flag = 'wp_sw_rewrite_flushed';
if (get_option($flag)) {
return;
}
flush_rewrite_rules(false);
update_option($flag, 1, true);
}
}
new WP_Perf_Service_Worker();
Créer une page offline (option simple)
Créez une page WordPress avec le slug offline. Contenu minimal, sans builder lourd. Si vous utilisez un builder, forcez un template léger (sinon votre page offline dépendra d’assets non cachés…).
Mesure : avant / après (réseau)
Avant : au second chargement, vos assets sont “revalidés” via HTTP cache, mais sur certains setups (cookies, cache-control, CDN mal réglé) ça redescend quand même. Après : le SW sert immédiatement depuis Cache Storage, puis revalide en arrière-plan (stale-while-revalidate).
Pour objectiver, faites 3 runs :
- 1er chargement (cold): normal.
- 2e chargement (warm): vous devez voir une chute du “transferred” côté DevTools.
- mode offline : la navigation doit afficher au moins la page offline + assets déjà cachés.
Étape 2 : Cache busting propre (versioning) et invalidation sans douleur
Le piège classique : “j’ai mis un SW, maintenant mes visiteurs gardent un vieux CSS pendant 2 jours”. Ça arrive quand :
- vous changez des fichiers sans changer leur URL (pas de version),
- vous ne changez pas le nom de cache (SW_VERSION),
- vous oubliez que le SW a un cycle de vie (install/waiting/activate).
Versionner le SW à chaque déploiement
Le plus propre : définir WP_SW_VERSION dans votre CI/CD (fichier wp-config.php ou variable d’environnement), par exemple le hash Git court.
<?php
// wp-config.php
// Exemple : injecté par votre pipeline (GitHub Actions, GitLab CI, etc.)
define('WP_SW_VERSION', '2026-04-12_9f3c1a2');
Forcer l’activation (optionnel, à utiliser avec prudence)
Si vous voulez éviter que le SW reste en “waiting”, vous pouvez envoyer un message SKIP_WAITING quand un nouveau SW est détecté. Attention : cela peut interrompre une session si vous avez de la navigation en cours. Sur un blog, c’est généralement acceptable.
(function(){
'use strict';
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.addEventListener('controllerchange', function () {
// Le nouveau SW contrôle la page : vous pouvez proposer un reload doux
console.log('[sw] controller changed');
});
navigator.serviceWorker.getRegistration().then(function(reg){
if (!reg) return;
reg.addEventListener('updatefound', function(){
var newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', function(){
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Option agressive : activer immédiatement
newWorker.postMessage({ type: 'SKIP_WAITING' });
}
});
});
});
})();
Étape 3 : Stratégies de cache par type (CSS/JS, images, polices, HTML)
La stratégie unique “cache-first pour tout” finit toujours par casser quelque chose. Sur WordPress, je segmente :
- CSS/JS : stale-while-revalidate (rapide, se met à jour en arrière-plan).
- Images : cache-first avec expiration (sinon votre cache gonfle).
- Polices : cache-first long (les polices changent rarement).
- HTML : network-first + fallback offline (sinon vous servez des pages périmées, et vous mélangez les états utilisateurs).
Améliorer le SW : images en cache-first + nettoyage
Ajoutez une logique d’expiration simple (nombre max d’entrées). Ce n’est pas aussi complet qu’une lib type Workbox, mais vous gardez le contrôle.
// Extrait à intégrer dans render_service_worker_js() (remplacez/complétez les handlers)
const MAX_IMAGE_ENTRIES = 200;
function isImageRequest(request) {
const url = new URL(request.url);
if (url.origin !== self.location.origin) return false;
return url.pathname.match(/.(png|jpg|jpeg|gif|webp|avif|svg)$/i);
}
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length <= maxEntries) return;
// Supprime les plus anciennes entrées (ordre d’insertion)
await cache.delete(keys[0]);
return trimCache(cacheName, maxEntries);
}
async function cacheFirstImages(request) {
const cache = await caches.open(CACHE_ASSETS);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request).catch(() => null);
if (response && response.ok && response.type === 'basic') {
await cache.put(request, response.clone());
await trimCache(CACHE_ASSETS, MAX_IMAGE_ENTRIES);
return response;
}
return new Response('', {status: 504, statusText: 'Gateway Timeout'});
}
// Dans fetch:
if (isImageRequest(request)) {
event.respondWith(cacheFirstImages(request));
return;
}
Ne pas casser les ressources dynamiques WordPress
Évitez de mettre en cache via SW :
- /wp-json/ si vos endpoints varient selon l’utilisateur (cookies, rôles). Vous pouvez le faire, mais il faut une stratégie par endpoint.
- admin-ajax.php (souvent POST, parfois GET mais contextuel).
- les pages avec paramètres de tracking si vous ne normalisez pas l’URL (sinon explosion du cache).
Ajoutez un “deny list” simple :
function shouldBypass(request) {
const url = new URL(request.url);
if (url.origin !== self.location.origin) return true;
// REST API : à gérer au cas par cas
if (url.pathname.startsWith('/wp-json/')) return true;
// Admin AJAX
if (url.pathname.endsWith('/wp-admin/admin-ajax.php')) return true;
// Tracking : évite de multiplier les entrées
const trackingParams = ['utm_source','utm_medium','utm_campaign','gclid','fbclid'];
for (const p of trackingParams) {
if (url.searchParams.has(p)) return true;
}
return false;
}
// Dans fetch:
if (shouldBypass(request)) return;
Étape 4 : Intégration propre avec Divi 5, Elementor, Avada (sans casser l’éditeur)
Les builders ajoutent :
- des previews (Elementor : elementor-preview),
- des endpoints de rendu,
- des variations d’assets selon la page (CSS généré).
Le SW doit rester “front-only”, et éviter les contextes d’édition/prévisualisation.
Garde-fous côté PHP : ne pas enregistrer le SW sur les pages d’édition
Ajoutez des conditions plus strictes dans enqueue_register_script() si nécessaire.
<?php
// À intégrer dans enqueue_register_script()
// Elementor : ne pas activer dans l’éditeur
if (did_action('elementor/loaded')) {
// Détection basique : query var elementor-preview
if (!empty($_GET['elementor-preview'])) {
return;
}
}
// Divi : évitez le Visual Builder (souvent via query var etfb)
if (!empty($_GET['et_fb'])) {
return;
}
// Avada/Fusion Builder : preview / builder
if (!empty($_GET['fb-edit'])) {
return;
}
Edge case : CSS généré par le builder
Elementor et certains setups Divi génèrent du CSS par page avec une URL stable. Votre SW le prendra comme un asset (OK). Le vrai problème arrive quand :
- le CSS est servi avec des headers no-store,
- ou l’URL ne change pas alors que le contenu change (cache HTTP et SW en conflit).
Si vous voyez “je modifie une page, mais je garde l’ancien style”, forcez le versioning des enqueues WordPress (voir étape suivante) et/ou bypass sur certains chemins (ex: /uploads/elementor/css/ si besoin).
Étape 5 : Core Web Vitals & mobile (preload, defer, navigation cache)
Le SW aide surtout les navigations répétées et les retours. Pour le “first hit”, combinez avec des optimisations de chargement.
Defer/async ciblé (sans casser jQuery dépendant)
Vous pouvez ajouter defer sur des scripts non critiques. Faites-le uniquement sur des handles connus. Une règle globale casse vite Divi/Elementor/Avada.
<?php
add_filter('script_loader_tag', function(string $tag, string $handle, string $src): string {
$defer_handles = [
'wp-sw-register',
// Ajoutez ici des scripts non critiques de votre thème enfant
];
if (in_array($handle, $defer_handles, true)) {
// Injecte defer proprement
if (str_contains($tag, ' defer')) {
return $tag;
}
return str_replace('<script ', '<script defer ', $tag);
}
return $tag;
}, 10, 3);
Preload des polices (si vous les servez en local)
Si vos polices sont critiques, préchargez-les. Ensuite, le SW les gardera en cache-first.
<?php
add_action('wp_head', function(): void {
// Exemple : police locale dans /wp-content/themes/votre-theme/assets/fonts/inter.woff2
$font_url = get_stylesheet_directory_uri() . '/assets/fonts/inter.woff2';
echo '<link rel="preload" href="' . esc_url($font_url) . '" as="font" type="font/woff2" crossorigin>' . "n";
}, 2);
Navigation cache : rester prudent
Mettre en cache l’HTML peut améliorer les retours, mais c’est là que WordPress devient piégeux (cookies, contenu personnalisé, paywalls, A/B tests). Le pattern “network-first” + fallback offline est le compromis le plus sûr pour un blog.
Configuration serveur
.htaccess (Apache) : s’assurer que service-worker.js est servi correctement
Si vous avez des règles agressives, vous pouvez empêcher WordPress de router /service-worker.js. En général, la règle de réécriture WordPress suffit, mais j’ai déjà vu des .htaccess qui bloquent les .js à la racine.
Ajoutez (au-dessus des règles WP si nécessaire) :
# .htaccess (Apache)
# Autoriser explicitement l'accès à /service-worker.js
<Files "service-worker.js">
Require all granted
</Files>
Headers cache côté serveur : ne pas contredire le SW
Le SW script lui-même doit être no-cache (on l’a déjà fait via PHP). Pour les assets, gardez un cache HTTP cohérent (long max-age + versioning URL). Sur WordPress, ça passe souvent par votre CDN ou votre serveur.
php.ini (ou config PHP-FPM) : éviter les surprises en prod
Le SW n’est pas lourd côté PHP (une réponse JS), mais si votre site est sous-dimensionné, tout souffre. Vérifiez au minimum :
; php.ini (exemples)
memory_limit = 256M
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
Vérification des résultats
1) Vérifier l’enregistrement et le scope
Dans le navigateur (DevTools > Application > Service Workers), vous devez voir :
- Service Worker actif sur scope “/”
- Cache Storage avec
wp-assets-...etwp-pages-...
Si vous voyez un scope limité (ex: /wp-content/), votre SW n’est pas servi à la racine. C’est l’erreur n°1.
2) Vérifier le taux de hit cache (sans outil externe)
Ajoutez temporairement un log côté SW (en dev uniquement) :
// Dans staleWhileRevalidate():
if (cached) {
// Commentaire : à supprimer en prod (bruit console)
console.debug('[sw] cache hit', request.url);
}
3) Mesurer via WebPageTest / Lighthouse
- Lighthouse (Chrome DevTools) : comparez 1er/2e chargement.
- WebPageTest : regardez la répétition des requêtes sur “repeat view”.
4) Vérifier côté WordPress que vos assets sont bien versionnés
Le SW ne compense pas des URLs d’assets non versionnées. Vérifiez que vos wp_enqueue_script/style ont un $ver stable et changé à chaque déploiement.
<?php
// Exemple thème enfant : version basée sur filemtime (bien en dev, moins en prod si FS distant)
add_action('wp_enqueue_scripts', function(): void {
$css_file = get_stylesheet_directory() . '/assets/app.css';
$ver = file_exists($css_file) ? (string) filemtime($css_file) : '1';
wp_enqueue_style(
'child-app',
get_stylesheet_directory_uri() . '/assets/app.css',
[],
$ver
);
}, 20);
Si les performances ne s’améliorent pas
Cas 1 : TTFB élevé (le SW ne peut rien faire)
Si vos métriques montrent un TTFB élevé, traitez d’abord :
- cache page (full-page cache) côté serveur/CDN,
- object cache (Redis/Memcached) pour réduire les requêtes DB,
- autoload options trop lourdes (WP-CLI plus haut).
Références : Transients API et WP_Object_Cache (si votre stack le remplace).
Cas 2 : vos assets ne sont pas “cacheables” (headers/CDN)
Si votre CDN renvoie Cache-Control: no-store sur vos CSS/JS, le SW peut toujours les mettre en cache (Cache Storage), mais vous risquez des incohérences si le serveur change le contenu sans changer l’URL. Corrigez le versioning, puis remettez des headers long cache côté CDN.
Cas 3 : conflit avec un plugin de cache / minification
J’ai vu des plugins qui :
- concatènent et changent les URLs à la volée,
- injectent des paramètres de query string,
- servent des bundles différents selon device.
Dans ce cas :
- bypassez les URLs instables dans le SW (deny list),
- ou stabilisez la génération (build statique) avant d’ajouter un SW.
Cas 4 : vous testez en navigation privée / cache désactivé
Erreur fréquente : tester en mode où le cache est désactivé (DevTools “Disable cache” coché) et conclure que “le SW ne marche pas”. Le SW et le cache HTTP sont affectés par ces options.
Pièges et erreurs courantes
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| /service-worker.js renvoie 404 | Règles de permaliens non flushées, rewrite non appliquée | Testez l’URL directe, vérifiez get_option('wp_sw_rewrite_flushed') |
Aller dans Permaliens et “Enregistrer”, ou flush contrôlé (code fourni) |
| Le SW s’enregistre mais scope incorrect | SW servi depuis un sous-répertoire | DevTools > Application > Scope | Servir le SW à la racine (endpoint /service-worker.js) |
| CSS/JS restent “anciens” après déploiement | SW_VERSION inchangée, assets non versionnés | Comparez URL des assets et contenu | Changez WP_SW_VERSION à chaque déploiement + versionnez les enqueues |
| Écran blanc en offline | Page offline absente, ou non cachée à l’install | Mode offline + navigation | Créer /offline/ et la precacher, ou fallback texte |
| wp-admin “bizarre” / formulaires cassés | Interception de requêtes non GET ou admin | Inspectez fetch events, regardez si POST est intercepté | Bypass strict : admin/login + request.method !== GET |
| Elementor/Divi builder ne charge plus correctement | Cache d’assets contextuels ou previews interceptées | Testez avec query vars builder (et_fb, elementor-preview) | Ne pas enregistrer le SW en contexte builder + bypass dans fetch |
| Vous copiez le code au mauvais endroit | Snippet collé dans functions.php d’un thème parent | Mise à jour du thème = code perdu | Thème enfant, plugin, ou MU-plugin |
| Erreur PHP après ajout du MU-plugin | Point-virgule manquant, PHP < 8.1, fichier encodé bizarrement | wp-content/debug.log | Corriger la syntaxe, vérifier version PHP, ré-uploader en UTF-8 sans BOM |
| Le SW “colle” malgré vos changements | Cycle de vie SW + cache navigateur | DevTools > “Update on reload”, “Unregister” | Incrémenter version + option SKIP_WAITING + purge caches |
Conseils de maintenance
- Version unique par déploiement : gardez WP_SW_VERSION synchronisé avec la version de vos assets (sinon invalidation incomplète).
- Surveillez la taille du cache : limitez images et pages, sinon certains navigateurs évinceront des entrées de façon imprévisible.
- Ne cachez pas tout : REST et HTML personnalisé demandent une stratégie par endpoint. Commencez “assets-only”.
- Testez sur mobile réel : en dev, tout a l’air parfait. Sur Android bas/milieu de gamme, les gains sont plus visibles… et les bugs aussi.
- Documentez le reset : gardez une procédure interne “unregister SW + clear site data” pour le support.
Ressources
- MDN – Service Worker API
- MDN – CacheStorage
- WordPress Developer Resources – Hooks
- add_rewrite_rule() (WordPress)
- flush_rewrite_rules() (WordPress)
- WordPress Core Trac (recherches perf, cache, rewrites)
- GitHub – wordpress-develop
- PHP.net – OPcache
FAQ
Est-ce que WordPress 6.9.4 fournit une API native pour les Service Workers ?
Non, pas dans le core. Vous implémentez le SW côté front (JS) et vous le servez via WordPress (rewrite/endpoint) ou via un fichier statique. Sur certains projets, je préfère un endpoint WordPress pour injecter une version de build.
Pourquoi ne pas utiliser Workbox ?
Workbox est excellent, mais ajoute une dépendance de build. Si vous avez déjà Vite/Webpack, foncez. Si vous voulez rester “WordPress-only”, le code ci-dessus couvre 80% des gains (assets + offline page) avec un contrôle maximal.
Le SW améliore-t-il le premier chargement (cold start) ?
Très peu. Il améliore surtout les vues répétées, la navigation interne, et la résilience réseau. Pour le cold start, travaillez plutôt le cache serveur/CDN, la réduction JS, et l’optimisation images.
Puis-je mettre en cache tout le HTML pour un site “instantané” ?
Techniquement oui, mais sur WordPress c’est risqué : pages personnalisées par cookies, bar admin, contenu conditionnel, paywalls, A/B tests, formulaires. Le pattern “network-first + fallback offline” est celui qui casse le moins.
Pourquoi bypass /wp-json/ ?
Parce que beaucoup d’endpoints REST varient selon l’utilisateur (nonce, auth, cookies). Vous pouvez cacher certains endpoints publics (ex: menus, taxonomies) mais faites-le explicitement, avec des clés de cache et une invalidation claire.
Est-ce compatible avec un CDN ?
Oui. Le SW s’exécute côté navigateur et peut coexister avec un CDN. Gardez juste une cohérence : URLs versionnées pour les assets, et évitez de capturer des ressources cross-origin si vous ne maîtrisez pas leurs headers.
Pourquoi mon CSS ne se met pas à jour malgré le SW_VERSION ?
Souvent parce que l’URL du CSS ne change pas et que le navigateur sert une version depuis un cache intermédiaire, ou parce que votre SW répond avec un cached entry et la revalidation échoue (offline). Corrigez le versioning des enqueues (paramètre $ver) et assurez-vous que le nouveau SW s’active (skipWaiting si nécessaire).
Comment “désinstaller” proprement un SW en production ?
Déployez un SW qui, à l’activation, supprime ses caches et appelle registration.unregister() côté client via un script dédié. En support, la méthode rapide reste DevTools > Unregister + Clear storage, mais ce n’est pas scalable pour tous les visiteurs.
Est-ce que ça peut casser un plugin de cache/minification ?
Oui si ce plugin génère des URLs instables ou change les bundles selon device. Dans ce cas, stabilisez la génération ou excluez certains chemins du SW.
Dois-je vider les caches après installation ?
Oui, au moins une fois : cache plugin/CDN + cache navigateur lors des tests. J’ai souvent vu des retours “ça ne marche pas” simplement parce que le testeur a gardé un ancien SW en “waiting”.