Mardi 9h, un SaaS Lyonnais nous envoie un rapport Lighthouse : LCP à 7,2 secondes, TBT à 1 400 ms sur la page de dashboard principale. Le site entier est bâti sur Material UI, version 5, avec Next.js 14. L’équipe front est compétente, la CI vérifie les bundles, les images sont au format WebP. Le coupable, on l’a trouvé dans le détail d’un seul import : une IconButton qui tirait 1 150 icônes SVG dans le bundle client, sans lazy-loading.
On te dira que Material Design est lourd par nature, que Google l’a conçu pour la cohérence visuelle, pas pour la performance. C’est une demi-vérité. Ce qui plombe les Core Web Vitals, ce n’est pas le design system lui-même, c’est la manière dont on l’intègre sans comprendre ce qui arrive au navigateur. Le problème n’était pas le framework, mais trois mauvais réflexes qu’on retrouve dans la plupart des projets Material UI qu’on inspecte.
Le bundle de 140 ko caché derrière un seul import d’icône
Le problème numéro un, c’est le coût d’import des icônes. Material UI propose une librairie de plus de 2 000 icônes, accessible via un import nommé : import DeleteIcon from '@mui/icons-material/Delete'. Comme chaque icône est un composant React isolé dans un fichier séparé, les bundlers modernes appliquent un tree-shaking basé sur les imports statiques. En théorie, seules les icônes importées finissent dans le build final.
En pratique, il y a trois pièges qui contournent le tree-shaking :
- Les imports dynamiques dans un composant qui assemble une
SvgIconselon une props rendent impossible l’analyse statique. Si tu as un tableau de noms d’icônes et que tu fais unrequire()ou un dynamic import basé sur une variable, le bundler embarque l’ensemble du répertoire. - Les ré-exports baril (
index.jsqui regroupe toutes les icônes) créent un point d’entrée unique qui force le bundler à inclure toutes les dépendances du module. Même si ton import ne concerne qu’une icône, le baril amène toute l’arborescence. - Le mode ESM de Material Icons n’est pas toujours activé dans les projets legacy. Si tu utilises la version CommonJS, le tree-shaking est quasi impossible.
Résultat : sur ce dashboard, le bundle principal contenait 1 150 icônes non utilisées, soit 140 ko de JavaScript parsé et exécuté avant que le moindre bouton ne devienne interactif. Le LCP de 7,2 secondes venait pour un tiers de ce volume, le reste du temps étant absorbé par le CSS-in-JS compilé côté client. On n’a pas changé de librairie. On a supprimé les barils, imposé des imports directs explicites, et réduit le nombre d’icônes chargées à 18.
Tu mesures l’impact avec un source-map-explorer sur le build de production. En une heure, le LCP passait sous les 3 secondes sans toucher au cache CDN ni au SSR.
Le vrai coût de l’icône Material : pas celui que tu crois
Au-delà du volume de code, le vrai coût se situe au niveau du paint et du layout. Material Design impose un style visuel précis : chaque icône est un composant SvgIcon avec une surcharge de props par défaut, une taille basée sur fontSize, une couleur héritée du thème via currentColor. Cette flexibilité a un prix.
À chaque affichage d’une icône, React exécute une fonction de rendu qui résout le contexte du thème MUI, vérifie la prop color, applique une media query si la taille est responsive, puis injecte le SVG dans le DOM. Si tu en affiches 200 en une seule page (listes, tableaux de données, boutons d’action), le navigateur exécute ces cascades CSS-in-JS 200 fois, même avec le SSR, parce que l’hydration doit recalculer le style côté client pour éviter les flash of unstyled content. L’INP s’envole.
On a mesuré le temps de blocage du thread sur le dashboard : 650 ms de TBT pour la seule phase d’hydration des icônes. En remplaçant les IconButton par des composants SVG bruts internes, sans passer par le système de thème, on a réduit cette phase à 90 ms. Le rendu visuel est identique, la différence est invisible pour l’utilisateur, mais la main thread libérée envoie l’INP à 140 ms au lieu de 700 ms.
Au-delà de 50 icônes affichées en même temps sur un tableau de bord, Material coûte plus cher en rendu qu’il n’apporte en cohérence. On le garde pour les composants complexes (dialogue, menu, formulaire), on code soi-même les éléments répétitifs.
CSS-in-JS et INP : le rendu serveur ne suffit plus
Material UI v5 utilise Emotion comme moteur de CSS-in-JS. En Next.js avec SSR, Emotion génère un bloc style critique dans le <head> de la réponse HTML. C’est propre pour le LCP : le contenu principal s’affiche sans attendre un fichier CSS externe. Le point noir, c’est l’INP.
Quand le bundle JavaScript s’hydrate, Emotion réconcilie les styles de tous les composants déjà affichés. Chaque composant MUI appelle useStyles ou styled() qui, au montage, recalcule une classe CSS unique basée sur les props. Ce recalcul est synchrone, il bloque le thread principal. Pour un composant unique comme un TextField, c’est négligeable. Pour un formulaire de 15 champs avec règles de validation dynamiques et changements de thème au survol, c’est un désastre.
Nous avons reproduit le scénario sur un formulaire d’onboarding de 30 champs utilisant TextField, FormControlLabel, Autocomplete. Le TBT atteignait 1 100 ms, dont 700 ms consacrées à l’hydratation des styles Emotion. La solution n’a pas été de migrer vers Tailwind ou CSS Modules, mais de basculer les composants de formulaire vers la version @mui/base, la surcouche sans style de MUI. Les fonctionnalités d’accessibilité et de logique restent intactes, on applique un style global via des fichiers CSS dédiés, et l’INP chute de 70 %.
Le diff de 20 lignes qui fait passer le TBT de 650 ms à 90 ms
Voici le diff exact qui a fait basculer l’INP.
On avait 50 occurrences d’un composant ActionCell qui embarquait trois IconButton Material, chacun avec une icône importée individuellement. Le code ressemblait à :
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import ArchiveIcon from '@mui/icons-material/Archive';
// ...
<IconButton onClick={…}><EditIcon /></IconButton>
On a remplacé ces icônes par des composants SVG internes, sans thème :
function EditIcon(props) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" {...props}>
<path d="M3 17.25…" fill="currentColor" />
</svg>
);
}
Et appliqué un style CSS global sur data-icon-type pour la couleur, sans passer par la cascade de thème. Le résultat : plus aucun appel à useTheme ni calcul de classe Emotion pour ces 150 icônes. Le TBT lié à l’hydratation des styles est passé de 650 ms à 90 ms. L’INP réel mesuré sur le terrain via la Search Console est tombé sous le seuil « Bon » en deux jours.
Tu gardes Material UI pour les composants qui ont besoin de sa logique d’état (le Dialog, le Select, le Drawer). Pour tout ce qui est purement visuel et en grand nombre, tu sors du système.
Les composants légers de MUI sont les plus chers
MUI n’embarque aucune métrique runtime. Pour savoir qu’un IconButton te coûte 12 ms de blocage, tu instrumentes toi-même. Le constat à l’arrivée : les composants réputés « légers » (Chip, Avatar, Badge) sont les plus chers quand ils sont multipliés, parce que chacun sollicite le thème et déclenche souvent une animation CSS au montage. Ouvre les DevTools sur une interaction, cherche updateStyle ou commitStyles dans la flame graph, les clusters sautent aux yeux.
Radix, Headless UI : changer de framework ne sauve pas l’INP
Tu pourrais être tenté de migrer vers Radix UI, Headless UI, ou même Ark UI pour gagner en performance. Leurs bundles sont plus petits, leur CSS est absent ou optionnel, et leur logique ne s’appuie pas sur un système de thème aussi invasif que celui de MUI. L’argument tient sur le papier.
En pratique, on a audité trois sites ayant migré de Material UI v5 vers Radix UI + Tailwind en pensant résoudre leur INP. Le TBT a baissé de 15 % en moyenne, pas de 50 %. Pourquoi ? Parce que la nouvelle implémentation reproduisait les mêmes erreurs structurelles : composants non mémoïsés, icônes chargées en bloc, refs non stabilisées. Le framework change, les mauvais réflexes restent.
Le vrai levier, c’est la discipline de ne jamais laisser un composant UI employer une logique de thème sans en connaître le coût d’exécution. Quand tu choisis Zustand pour la gestion d’état d’un composant de sélection complexe – on a écrit un article sur le sujet –, tu limites le nombre de re-rendus en cascade. State management et Zustand montrent que le choix de la bibliothèque est secondaire par rapport à l’architecture du flux de données. La même règle s’applique aux design systems : la performance ne vient pas du nom du package, elle vient de la compréhension fine de ce qu’il exécute au runtime.
Nos outils de refactoring assisté, qu’on utilise avec Cursor ou via Claude Code pour automatiser l’extraction des composants critiques, nous aident à repérer les imports non optimisés. La comparaison Claude Code vs Cursor IDE explique comment on utilise ces assistants pour auditer un codebase en une matinée. On détecte les IconButton à remplacer en série, on applique les transformations, et on divise le temps d’intervention par trois.
Questions fréquentes
Est-ce que Material UI v6 résout les problèmes d’INP ?
La version 6 introduit Pigment CSS, un moteur zero-runtime qui extrait les styles en CSS statique pendant la compilation. Cela supprime le coût du CSS-in-JS à l’exécution et améliore l’INP de manière significative, à condition d’avoir migré toute la base de composants. En pratique, la migration est lourde : les APIs de style changent, et le support des thèmes dynamiques est limité. Sans une adoption complète, tu conserves une partie d’Emotion et le gain est partiel.
Peut-on utiliser Material UI avec un LCP sous 2,5 secondes ?
Oui, à condition d’avoir un rendu serveur efficace, des imports d’icônes strictement contrôlés, et aucun composant MUI non critique chargé de manière synchrone. On a atteint 2,1 secondes sur un tableau de bord de 60 lignes en combinant @mui/base, chargement lazy des modales, et SVG bruts pour les pictogrammes répétitifs. L’effort porte sur la discipline d’import, pas sur le framework.
Les performances sont-elles meilleures avec Shadcn/ui qu’avec Material Design ?
Shadcn/ui base ses composants sur Radix et Tailwind, ce qui élimine le runtime CSS et allège le bundle. Si tu copies le code dans ton projet plutôt que d’importer une librairie, tu évites les déduplications et le poids mort. L’avantage est réel sur le TBT initial. En revanche, si tu n’optimises pas tes propres composants (mémoïsation, chargement conditionnel), tu constateras un INP aussi dégradé qu’avec MUI. La différence de performance tient toujours à la maîtrise du cycle de rendu, pas à la provenance des composants.