Skip to main content

5 min read #angular #performance #images #lcp #frontend

Two letters that fixed my LCP: ngSrc

NgOptimizedImage is the rare optimization that argues with you when you use it wrong. Lazy by default, priority for the hero, mandatory dimensions against CLS, and automatic srcset if you tell it where your CDN is.

Images are where Core Web Vitals go to die. The biggest element on most pages is an image (that's literally what LCP measures, most of the time), the thing that jumps the layout around is usually an image arriving late, and the bytes that dwarf your carefully code-split bundle are — images. The fix in Angular costs two letters:

<img ngSrc="/assets/team.jpg" width="800" height="533" alt="The team" />

Same element, ngSrc instead of src, plus the directive imported. What you get for that is a small pile of best practices that you no longer have to remember.

Lazy by default, and the one image that mustn't be

Every ngSrc image is loading="lazy" and decoding="async" unless told otherwise — correct for every image below the fold, which is most of them. The exception is the point: your LCP image, the hero, must be the opposite — fetched as early as possible. That's priority:

<img ngSrc="/assets/hero.jpg" width="1200" height="600" priority alt="..." />

One attribute sets eager loading and fetchpriority="high", and the SSR pipeline emits a preload hint so the browser starts the download before it has even parsed down to the <img> tag. And here's my favorite part of this directive's design: if you forget — if the image Angular detects as your LCP element isn't marked priority — it tells you in the console, in dev mode. An optimization that audits its own usage.

The mandatory dimensions are the feature

width and height are required, and people's first reaction is annoyance — mine was. But this is CLS prevention by API design: with intrinsic dimensions the browser reserves the right box before a single byte arrives, and nothing jumps when the pixels land. The directive turned "remember to set dimensions" — a discipline that erodes — into "the build won't let you forget". (The numbers define the ratio; CSS still controls display size.) For the genuinely-unknown case — a user-uploaded background, a CMS image — there's fill, which absolutely-positions the image into a sized parent, paired with object-fit in your CSS.

The srcset you were never going to write by hand

Serving a 1200px image to a 360px phone screen wastes most of the bytes. The honest fix is a responsive srcset, and almost nobody writes those by hand because they're tedious and they rot. With a loader configured, the directive generates them:

// app.config.ts — one line, pick your CDN
provideImgixLoader('https://my-site.imgix.net/'),

// every ngSrc image now emits srcset variants automatically

Built-in loaders exist for the usual suspects (Imgix, Cloudinary, ImageKit, Netlify…), and a custom loader is a function mapping (src, width) to a URL — ten lines if your backend can resize. This is the difference between the directive being "nice" and being a bandwidth line-item: the phone gets the 400px file, the retina desktop gets the 1600px one, and nobody on the team maintains a single srcset string.

The honest scope

What NgOptimizedImage doesn't do: convert formats, crush file sizes, or host anything — that's the CDN/build pipeline's job; the directive just asks for the right variant at the right moment. And on a page with three images, the gains are real but modest. The compounding case is the image-heavy app — listings, galleries, media — where lazy-by-default alone can cut initial page weight dramatically. Run Lighthouse before and after on one route. That number is how you sell the two-letter change to whoever approves the sprint.