📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~8 min de lecture

NgOptimizedImage : 5 erreurs qui plombent ton LCP (et ton score Lighthouse)

Tu ouvres Lighthouse sur ta belle landing page. Performance : 64. Tu scrolles le rapport, et c'est toujours la même litanie : "Largest Contentful Paint 4.1 s", "Image elements do not have explicit width and height", "Serve images in next-gen formats", "Defer offscreen images". Neuf fois sur dix, le coupable n'est pas ton bundle JS. C'est une <img>.

L'image est presque toujours l'élément LCP d'une page de contenu : le visuel hero, la photo d'article, le logo produit. Si elle arrive en retard ou décale le layout en se chargeant, c'est tout ton score Core Web Vitals qui trinque. Et Google s'en sert pour ton classement.

NgOptimizedImage (stable depuis Angular 15, peaufiné jusqu'en v20) est la réponse first-party d'Angular à ce problème. Mais la directive ne fait pas de magie : mal utilisée, elle ne change rien, voire elle ajoute du bruit dans la console. Voici les 5 erreurs qui font la différence entre "j'ai mis la directive" et "mon LCP est passé sous 2,5 s".


TL;DR

Erreur Symptôme Correctif
<img src> brut CLS, lazy mal placé, pas de srcset ngSrc + NgOptimizedImage
Pas de width/height Layout shift, warning console dimensions obligatoires (ou fill)
Oublier priority sur le LCP image hero lazy-loadée, LCP retardé priority sur la 1re image visible
Pas de ngSrcset + sizes image 2000px servie sur mobile ngSrcset + sizes responsive
src en dur sans loader pas de WebP/AVIF, pas de resize IMAGE_LOADER vers ton CDN

La règle d'or : une seule image en priority par page (le LCP), tout le reste en lazy automatique.


Le point de départ : l'<img> qui fait tout de travers

Voici un composant carte d'article classique. Rien de choquant à la lecture, et pourtant il coche toutes les cases du mauvais élève.

import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'app-article-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article class="card">
      <img [src]="cover()" alt="" class="cover" />
      <h3>{{ title() }}</h3>
    </article>
  `,
})
export class ArticleCard {
  cover = input.required<string>();
  title = input.required<string>();
}

Les problèmes, dans l'ordre où Lighthouse va te les reprocher :

  • Aucune dimension : le navigateur ne connaît pas le ratio avant le téléchargement, donc le texte saute quand l'image arrive. C'est du CLS pur.
  • Chargement eager par défaut : toutes les cartes téléchargent leur image immédiatement, y compris celles à 3000px sous la ligne de flottaison.
  • Une seule résolution : la même image full HD part vers un iPhone comme vers un écran 4K.
  • Format imposé : tu sers le JPEG d'origine alors que le navigateur accepterait du WebP deux fois plus léger.

NgOptimizedImage adresse les quatre, mais seulement si tu lui donnes les bonnes infos.


Erreur n°1 : utiliser <img src> au lieu de ngSrc

La base : importer la directive et remplacer src par ngSrc. La directive est standalone, tu l'ajoutes directement dans les imports du composant.

import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';

@Component({
  selector: 'app-article-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [NgOptimizedImage],
  template: `
    <article class="card">
      <img [ngSrc]="cover()" width="640" height="360" alt="" class="cover" />
      <h3>{{ title() }}</h3>
    </article>
  `,
})
export class ArticleCard {
  cover = input.required<string>();
  title = input.required<string>();
}

Rien qu'avec ça, tu gagnes le lazy-loading automatique (loading="lazy" + decoding="async" posés pour toi), le fetchpriority intelligent, et une série de warnings en dev qui t'alertent si l'image affichée est beaucoup plus grande que sa taille intrinsèque. C'est le point d'entrée, pas la ligne d'arrivée.

Attention au alt vide : un alt="" est valide uniquement pour une image purement décorative. Pour une vignette d'article, mets un vrai texte alternatif. NgOptimizedImage ne te le rappellera pas, mais l'accessibilité et le SEO si.


Erreur n°2 : oublier width / height (ou mal gérer le responsive CSS)

NgOptimizedImage exige width et height. Ce ne sont pas des dimensions d'affichage : ce sont les dimensions intrinsèques du fichier, qui servent à calculer le ratio et à réserver l'espace. Le navigateur déduit l'aspect-ratio et bloque le décalage avant même que l'image arrive. Fin du CLS.

Le piège classique, c'est de vouloir une image fluide en CSS et de croire que width/height vont la figer. Non. Tu fixes les attributs HTML (le ratio) et tu laisses le CSS gérer la taille rendue :

.cover {
  width: 100%;
  height: auto; /* indispensable : conserve le ratio donné par width/height */
}

Sans height: auto, l'image s'étire et tu perds le bénéfice du ratio. Avec, tu as une image responsive sans layout shift.

Et quand tu ne connais pas le ratio à l'avance ? Image issue d'un CMS, bannière qui doit remplir un conteneur de taille variable ? C'est le cas d'usage de fill :

<div class="hero-frame">
  <img [ngSrc]="banner()" fill priority alt="Bannière de la formation" />
</div>
.hero-frame {
  position: relative; /* obligatoire, sinon l'image s'échappe du flux */
  aspect-ratio: 16 / 9;
}
.hero-frame img {
  object-fit: cover;
}

En mode fill, tu ne fournis ni width ni height, l'image se cale en absolu sur son parent. Mais le parent doit être positionné (relative, absolute ou fixed), sinon l'image part dans le décor. C'est l'erreur n°1 reportée sur fill dans les issues GitHub.


Erreur n°3 : ne pas mettre priority sur l'image LCP

C'est l'erreur la plus coûteuse, et la plus contre-intuitive.

Par défaut, NgOptimizedImage met tout en lazy-loading. Excellent pour les vignettes en bas de page. Catastrophique pour ton image hero, qui est justement l'élément LCP. Une image lazy n'est découverte par le navigateur qu'après le parsing et le layout : elle démarre trop tard, et ton LCP explose.

La directive a un garde-fou : en dev, si elle détecte qu'une image est dans le viewport initial sans priority, elle te crache un warning NG02955. Écoute-le.

<!-- L'image hero : visible immédiatement, c'est le LCP -->
<img [ngSrc]="hero()" width="1280" height="720" priority alt="EasyAngularKit" />

<!-- Les vignettes plus bas : lazy automatique, surtout PAS priority -->
@for (article of articles(); track article.slug) {
  <img [ngSrc]="article.cover" width="640" height="360" alt="" />
}

Ce que priority fait sous le capot : il pose fetchpriority="high", désactive le lazy-loading, et injecte un <link rel="preload"> dans le <head>. Le navigateur va chercher l'image en priorité, dès la découverte du HTML.

La règle à graver : une seule image priority par page, celle qui est ton LCP. Si tu en mets cinq, tu n'en priorises plus aucune et tu sursollicites la bande passante au pire moment.


Erreur n°4 : servir une image 2000px sur un mobile 360px

width/height corrigent le CLS, priority corrige le timing. Mais tu sers toujours le même fichier à tout le monde. Sur un mobile, télécharger une image trois fois trop grande, c'est des centaines de Ko gaspillés et un LCP qui traîne sur réseau lent.

La solution, c'est ngSrcset couplé à sizes. NgOptimizedImage génère l'attribut srcset complet à partir de ton loader (voir erreur n°5), et sizes indique au navigateur quelle largeur d'affichage anticiper.

<img
  [ngSrc]="hero()"
  width="1280"
  height="720"
  priority
  ngSrcset="400w, 800w, 1200w, 1600w"
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="EasyAngularKit"
/>

Ici, tu déclares 4 largeurs candidates. Le navigateur regarde sizes, calcule la largeur réelle d'affichage selon le viewport et la densité d'écran, puis pioche le bon cran dans le srcset. Un mobile prendra le 400w ou 800w, un écran Retina large ira chercher le 1600w. Tout le monde reçoit l'image juste assez grande, jamais plus.

Sans loader configuré, ngSrcset ne sert à rien : Angular n'a aucun moyen de fabriquer les variantes d'URL. D'où l'erreur suivante.


Erreur n°5 : pas de loader, donc pas de transformation ni de next-gen format

Par défaut, NgOptimizedImage utilise un loader "no-op" : il renvoie l'URL telle quelle. Tu n'as ni resize, ni conversion WebP/AVIF, ni srcset exploitable. Tu loupes le "Serve images in next-gen formats" de Lighthouse.

Si tu héberges tes images sur un CDN d'images (Cloudinary, Imgix, ImageKit, Netlify…), Angular fournit des loaders prêts à l'emploi. Tu les branches une fois, au niveau de l'application :

import { ApplicationConfig } from '@angular/core';
import { provideImgixLoader } from '@angular/common';

export const appConfig: ApplicationConfig = {
  providers: [
    provideImgixLoader('https://easyangularkit.imgix.net/'),
  ],
};

À partir de là, ngSrc="hero.jpg" devient une URL CDN, et ngSrcset="400w, 800w, 1200w" génère les variantes redimensionnées au format optimal négocié.

Tu utilises un CDN maison ou une API de transformation custom ? Tu fournis ton propre loader via le token IMAGE_LOADER :

import { ApplicationConfig } from '@angular/core';
import { IMAGE_LOADER, ImageLoaderConfig } from '@angular/common';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: IMAGE_LOADER,
      useValue: (config: ImageLoaderConfig) => {
        const width = config.width ? `&w=${config.width}` : '';
        return `https://cdn.easyangularkit.com/${config.src}?fm=webp${width}`;
      },
    },
  ],
};

Angular appelle ta fonction une fois par cran de ngSrcset (en passant config.width), et une fois pour le src de base. Tu contrôles entièrement le format et le redimensionnement. C'est la pièce qui transforme NgOptimizedImage d'un correcteur de CLS en véritable pipeline d'optimisation.


Before / After

Le composant de départ, en <img src> brut, sur une page hero :

<img [src]="hero()" class="cover" alt="" />
  • LCP : ~4,0 s sur mobile (image découverte tard, format JPEG plein format)
  • CLS : visible, le texte saute au chargement
  • Poids transféré : l'image full HD, quel que soit l'écran

La version finale, avec tous les correctifs :

<img
  [ngSrc]="hero()"
  width="1280"
  height="720"
  priority
  ngSrcset="400w, 800w, 1200w, 1600w"
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="Formation Angular EasyAngularKit"
/>
  • LCP : préchargé via priority, image servie en WebP à la bonne taille
  • CLS : zéro, le ratio est réservé dès le HTML
  • Poids transféré : la variante juste assez grande pour l'écran

Les chiffres exacts dépendent de ton réseau, de ton CDN et de ta page : mesure toujours sur ton projet, pas sur une promesse de blog. Mais le pattern est solide, et c'est lui qui débloque les warnings Lighthouse les plus pénalisants.


Récap actionnable

La checklist à passer sur chaque image de ton app :

  1. Remplace src par ngSrc et importe NgOptimizedImage dans le composant.
  2. Fournis width + height (dimensions intrinsèques) ou passe en fill avec un parent position: relative. Ajoute height: auto en CSS pour le responsive.
  3. Mets priority sur l'unique image LCP de la page, jamais sur les autres. Si Angular te sort un warning NG02955, c'est qu'il t'en manque un.
  4. Ajoute ngSrcset + sizes sur les images qui s'affichent à des tailles variables selon le viewport.
  5. Configure un IMAGE_LOADER (loader CDN fourni ou custom) pour obtenir resize + WebP/AVIF automatiques.

Et une règle bonus qui évite 90% des régressions : laisse les warnings de dev de NgOptimizedImage allumés. La directive est ta première ligne d'audit, bien avant Lighthouse. Quand la console est propre, ton score l'est généralement aussi.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.