Esc
 Naviguer  Ouvrir Esc Fermer
Aller au contenu

13.2 — Optimisations frontend

🎯 Objectif : appliquer les leviers qui font passer une page de 4 s à 1,5 s. Images, fonts, CSS, JS, cache, edge — chacun pèse, et leur combinaison fait la différence.

À l'issue de cet axe, tu sauras :

  • Servir des images modernes (AVIF/WebP) avec srcset et fetchpriority
  • Charger les polices sans FOIT/FOUT pénalisant
  • Réduire le JS critique : code splitting, dynamic import, RSC, islands
  • Inliner le CSS critique et différer le reste
  • Mettre en place un CDN edge avec cache HTTP correct

Confirmé 11 min prérequis : axes 5-9 lus

Tous les leviers ne se valent pas. Voici l’ordre de gain habituel, du plus fort au plus faible :

LevierGain typique sur LCP/INP
Images modernes + dimensionnement correct-1 à -3 s LCP
Réduction du JS critique (code splitting, RSC)-1 à -2 s LCP, INP / 2
CDN + cache HTTP correct-200 à -800 ms TTFB → LCP
Polices optimisées (font-display, preload)-200 à -500 ms FCP/LCP
CSS critique inliné-100 à -300 ms FCP
Prefetch / preload-50 à -200 ms LCP
Compression Brotli-10 à -30 % de poids

Optimise dans cet ordre. Inutile d’inliner 4 KB de CSS critique si ton bundle JS pèse 800 KB.


FormatCompression vs JPEGCompatibilité 2026
AVIF~50 % plus légerTous navigateurs majeurs
WebP~30 % plus légerUniversal
JPEG progressifRefUniversal — fallback
PNGÀ éviter pour les photosUniversal

Servir AVIF en priorité, WebP en fallback, JPEG en ultime fallback :

<picture>
<source type="image/avif" srcset="hero.avif">
<source type="image/webp" srcset="hero.webp">
<img src="hero.jpg" alt="..." width="1200" height="675">
</picture>

Servir une image de 4000 px à un mobile = gâchis. Le navigateur choisit la bonne taille avec srcset + sizes :

<img
src="hero-800.avif"
srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1600.avif 1600w"
sizes="(max-width: 768px) 100vw, 800px"
alt="..."
width="800"
height="450"
fetchpriority="high"
loading="eager"
>
AttributRôle
srcsetListe des sources avec leur largeur réelle
sizesIndique la taille de rendu prévue selon viewport
width / heightRéservent l’espace → pas de CLS
fetchpriority="high"Pour le LCP element uniquement
loading="lazy"Pour les images sous la fold
decoding="async"Décodage non-bloquant

Les CDN d’images (Cloudflare Images, imgix, Cloudinary, ImageKit) génèrent les variantes à la volée :

https://imgcdn.example/hero.jpg?w=800&fm=avif&q=70

Avantage : tu stockes une seule image, le CDN sert 50 variantes.


Deux problèmes connus :

  • FOIT (Flash of Invisible Text) : texte invisible pendant que la police charge. Pénalise LCP.
  • FOUT (Flash of Unstyled Text) : police fallback puis swap → CLS si métriques différentes.
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap; /* éviter FOIT */
ascent-override: 90%; /* aligner les métriques */
size-adjust: 105%;
}
<link rel="preload" href="/fonts/inter-var.woff2" as="font"
type="font/woff2" crossorigin>
TechniqueEffet
font-display: swapTexte affiché avec fallback, swap à l’arrivée
woff2 + variable font-30 à -70 % de poids vs polices statiques
preloadDémarre le download dès le HTML parsé
ascent-override / size-adjustLe fallback a la même hauteur de ligne → pas de CLS
Self-hostÉlimine la latence du DNS Google Fonts

Le CSS du above-the-fold (ce qui est visible sans scroller) est inliné directement dans le <head>. Le reste est chargé en <link rel="stylesheet"> ou via JS.

<head>
<style>/* CSS critique extrait : ~10 KB max */</style>
<link rel="preload" as="style" href="/main.css"
onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/main.css"></noscript>
</head>

Outils :

  • Penthouse / critical (npm) — extrait le CSS du fold.
  • Astro / Next.js — gèrent ça quasi-automatiquement avec leurs primitives.
  • Tailwind v4 + JIT → CSS final ~10-15 KB sur une page typique : pas vraiment besoin de le splitter.

@layer permet de piloter la cascade sans !important, et @scope (Chromium) limite la portée d’un sélecteur :

@layer reset, base, components, utilities;
@scope (.card) {
h2 { font-size: 1.5rem; }
}

Effet perf : moins de CSS overrides, moins de spécificité grimpante, donc moins de calcul de styles.


Charger l’éditeur Markdown sur la page d’accueil = 200 KB de JS pour un visiteur qui ne l’utilisera jamais.

// Mauvais — chargé même si l'utilisateur n'ouvre jamais l'éditeur
import { MarkdownEditor } from './MarkdownEditor';
// Bon — chargé au clic sur "Éditer"
const handleClick = async () => {
const { MarkdownEditor } = await import('./MarkdownEditor');
// ...
};

En React/Next, lazy + Suspense :

const Editor = lazy(() => import('./MarkdownEditor'));
<Suspense fallback={<Skeleton/>}>
<Editor />
</Suspense>
// ❌ Importe TOUTE la lib (~70 KB)
import _ from 'lodash';
_.debounce(fn, 200);
// ✅ ~2 KB
import debounce from 'lodash/debounce';
debounce(fn, 200);
// ✅ Mieux encore : lodash-es ou la fonction native

Inspecte ton bundle :

Fenêtre de terminal
npx vite-bundle-visualizer # Vite
npx @next/bundle-analyzer # Next.js

Deux paradigmes 2026 pour réduire drastiquement le JS envoyé :

RSC (Next.js 16 App Router, Remix, etc.) Le composant rend côté serveur, le HTML arrive sans son JS. Seuls les composants marqués 'use client' sont hydratés.

// app/page.tsx — Server Component (défaut Next.js)
import { db } from '@/db';
export default async function Home() {
const posts = await db.posts.findAll();
return <PostList posts={posts} />; // 0 JS shippé
}

Astro Islands Astro envoie 0 JS par défaut. Tu opt-in par composant :

---
import Counter from './Counter.svelte';
---
<Counter client:visible />
<!-- chargé seulement quand l'élément entre dans le viewport -->
Directive AstroQuand l’hydrater
client:loadAu chargement
client:idleQuand le main thread est libre
client:visibleQuand visible (IntersectionObserver)
client:mediaSelon une media query
client:onlySkip le SSR, rend uniquement côté client

Le main thread doit rendre la main aux interactions toutes les 50 ms. Si tu as un calcul lourd :

// ❌ Bloque 200 ms — INP catastrophique
function processItems(items) {
for (const item of items) heavyWork(item);
}
// ✅ Yield au navigateur entre chaque batch
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
heavyWork(items[i]);
if (i % 50 === 0) await scheduler.yield(); // API native 2026
}
}

Pour des calculs vraiment lourds, Web Worker :

const worker = new Worker(new URL('./calc.ts', import.meta.url), { type: 'module' });
worker.postMessage(data);
worker.onmessage = (e) => setResult(e.data);

<!-- DNS + TLS handshake en avance vers un domaine critique -->
<link rel="preconnect" href="https://api.example" crossorigin>
<!-- Ressource utilisée DANS la page courante, prioritaire -->
<link rel="preload" href="/hero.avif" as="image" fetchpriority="high">
<!-- Ressource utilisée par la PROCHAINE page (faible priorité) -->
<link rel="prefetch" href="/dashboard.js">
HintQuand
preconnectAPI critique, CDN, fonts externes
dns-prefetchIdem mais sans TLS (cas rares)
preloadAsset critique chargé en async (font, hero image, script crucial)
prefetchPage de destination probable (ex: lien hover)
modulepreloadModule ESM critique

Ne pas en abuser. 10 preload au lieu de 1 = bottleneck CPU/réseau.


# Asset versionné (hash dans l'URL) → immuable
Cache-Control: public, max-age=31536000, immutable
# HTML → court, revalidation rapide
Cache-Control: public, max-age=0, must-revalidate
ETag: "abc123"
# Réponse API qui change rarement → SWR
Cache-Control: public, max-age=60, stale-while-revalidate=600

stale-while-revalidate est le meilleur ami du LCP : on sert le cache immédiatement et on revalide en arrière-plan.

Faire tourner le serveur proche de l’utilisateur divise le TTFB par 3-5 sur les zones lointaines.

// Vercel Edge / Cloudflare Workers
export const runtime = 'edge';
export default async function handler(req: Request) {
const data = await fetch('https://api.example/posts', {
next: { revalidate: 60 }, // ISR Next.js
});
return Response.json(await data.json());
}
PatternQuand utiliser
SSG (Static Site Generation)Contenu identique pour tous (blog, marketing)
ISR (Incremental Static Regeneration)Contenu qui change toutes les X min (catalogue produits)
SSR EdgeContenu personnalisé léger (dashboard, A/B test)
CSR (Client-Side Rendering)Apps internes, dashboards très interactifs

Active Brotli sur ton CDN/serveur :

FormatRéduction texte/JSONUniversel ?
Brotli (br)~20 % de mieux que gzipQuasi (sauf navigateurs très anciens)
gzipRéférenceUniversal

Brotli avec niveau 4 dynamique + niveau 11 statique (assets pré-compressés au build) est la norme 2026.


LevierEffet
HTTP/3 (QUIC)Évite le head-of-line blocking sur réseaux mobiles
Early Hints (103)Le serveur push des preload avant la réponse complète
Service WorkerCache offline + asset shell

Ton CDN (Cloudflare, Fastly, Vercel) les active en quelques clics.


Tu as une image hero 4000×3000 servie en JPEG (1,8 MB) sans srcset. Quel est le levier qui aura le plus d'impact sur LCP ?
Tu vois un long task de 380 ms dans Performance lors du clic sur un bouton. INP catastrophique. Quelle stratégie ?
Tu utilises Next.js avec `<Image>` automatique. Sur quel pattern faut-il rester vigilant ?
Tu hésites entre RSC (Next.js) et Astro Islands pour un site marketing très peu interactif. Lequel choisir et pourquoi ?


Suite : 13.3 — Optimisations backend pour s’attaquer au TTFB et aux requêtes.