I'm always tuning my portfolio, miguelcarino.com, and I'd rather measure a change than trust how it feels. My measuring stick is Lighthouse, Google's free page audit, built into Chrome's developer tools. It loads a page the way a mid-range phone on a slow connection would, then scores it out of 100 in four areas: performance, accessibility, best practices and search basics.
On a recent run, mobile came back at 79. The number that explained it sat a few lines further down. The largest contentful paint (LCP), the moment the biggest thing on screen finishes drawing, took 5.2 seconds. On most pages here, the biggest thing on screen is the hero photo, the wide banner image across the top.
The home page hero, at the 960-pixel width Lighthouse's test phone asks for, was a 162 KB JPEG. Its twin in AVIF, a newer image format, came out of the same source photo at 34 KB.
So if the smaller file was sitting right there, why was I still sending the big one?
How the JPEGs stayed
JPEG never failed in a way I could see. Every browser opens it. My image script took one source photo and resized it to five widths, and the template listed them in a srcset. The format was a decision I made once, early, and never had a reason to look at again.
The page around the photo had picked up habits that made it worse. I had added a preload for the home hero's JPEG in index.html, the one file every route shares. So every page on the site downloaded the home page's photo, at low priority, while it was also trying to download its own. The analytics script, larger than the app itself, loaded early too.
Every page was paying for one page's picture.
Images weren't the only reason for the 79, and I fixed the analytics timing and the startup bundle in the same pass. But the hero was the LCP element. Its bytes were the ones the score was waiting on, so they came first.
Letting the browser choose
AVIF borrows its compression from AV1, a video codec. That's how the home hero shrank to about a fifth of its JPEG size with no difference I could see. The post heroes landed nearer half. About 96% of browsers in use can decode it, by caniuse's count. The other 4% is the catch. Swap every JPEG for an AVIF and those visitors get a broken-image icon where the hero should be.
The fix is old, plain HTML. A <picture> element works like the instruction sheet in a box of flat-pack furniture: the same steps printed in several languages, and each reader stops at the first one they can read. The browser walks the <source> elements in order, skips any whose type it can't display without even asking the server, and falls back to the <img> when none fit.
The hero on this site, trimmed to three widths and the attributes that matter here:
<picture>
<source
type="image/avif"
srcset="hero-640.avif 640w, hero-960.avif 960w, hero-1440.avif 1440w"
sizes="100vw"
/>
<img
src="hero-1440.jpg"
srcset="hero-640.jpg 640w, hero-960.jpg 960w, hero-1440.jpg 1440w"
sizes="100vw"
width="1440"
height="810"
alt=""
fetchpriority="high"
/>
</picture>
Lighthouse's test phone requests hero-960.avif and gets 34 KB. A browser that can't decode AVIF never sees the first line as an option and takes hero-960.jpg. The <img> is still the element on the page in both cases. The <source> only changes which file fills it, so the alt, the dimensions and fetchpriority all live on the <img> and apply either way.
The image formats section of the demo shows this on one photo. It loads the same 640-pixel image as JPEG, as WebP (Google's older modern format, which nearly every browser decodes) and as AVIF and reads each file's size from the browser's own Resource Timing entries, so the numbers are whatever the server actually sent. On the current deploy that's 17.9 KB, 11.0 KB and 8.2 KB. A little further down, the <picture> example reports which file your browser chose.
Where NgOptimizedImage fits
Angular's NgOptimizedImage does a lot for the one image that is the page's LCP. Mark it priority and it sets fetchpriority="high", loads it eagerly and adds a preload link when the page renders on the server. Give it ngSrcset and a loader, and it writes the srcset for you.
What it doesn't support is <picture>. Asked whether it does, Angular's docs answer "No, but this is on our roadmap." My read of why starts with where a loader runs. It builds each URL while the page renders, often on the server, with no idea which formats the visitor's browser decodes. A loader that points at static files, like mine, has to pick one format for everyone.
The demo's loader, trimmed, picks WebP:
import { ImageLoaderConfig } from '@angular/common';
const FORMATS_DIR = '/assets/performance/formats';
export function formatsLoader({ src, width }: ImageLoaderConfig): string {
return `${FORMATS_DIR}/${src}-${width ?? 640}.webp`;
}
And the template that uses it, trimmed to the attributes that matter here. The loader is provided to that one component, so the rest of the app keeps Angular's default:
<img
ngSrc="palms"
ngSrcset="640w, 960w, 1280w"
sizes="(max-width: 48rem) 100vw, 40rem"
width="1280"
height="720"
alt="The same palm trees, served through NgOptimizedImage and a custom loader."
/>
Angular turns ngSrcset into a real srcset by calling the loader once per width. The rendered <img>, trimmed to that attribute:
<img
srcset="/assets/performance/formats/palms-640.webp 640w, /assets/performance/formats/palms-960.webp 960w, /assets/performance/formats/palms-1280.webp 1280w"
/>
The NgOptimizedImage example shows the file your browser received, and it's always WebP.
For my heroes I chose the <picture>. That meant writing fetchpriority and the srcset by hand, which is a few lines of template I'd otherwise get for free. I didn't miss the preload link. The site is prerendered, so the <picture> is already in the HTML the server sends, and the browser finds it on its first read of the page.
The options I passed on
WebP everywhere came closest. One format, no <picture>, and NgOptimizedImage handles all of it. On the demo photo, though, AVIF comes in about a quarter smaller than WebP, and on a page whose slowest moment is a photo, I wanted that quarter. If I needed the directive's conveniences across hundreds of images, WebP through a loader is the choice I'd make, and it's the one the demo makes.
For a site with thousands of user-uploaded photos, I'd start with an image CDN (content delivery network). It stores one original and picks the format per request, which is the one thing a static loader can't do, and Angular ships loaders for several, including Cloudflare, Cloudinary, ImageKit, Imgix and Netlify. This site has a few dozen photos, built once at deploy and served as static files from shared hosting. A CDN would put an outside account and its uptime behind the most visible thing on every page.
Format negotiation has a catch, though. Chrome and Firefox list image/avif in the Accept header they send with image requests. Safari decodes AVIF, but according to MDN's table its header doesn't say so. A server that goes by the header alone would hand Safari a bigger file than it needs. If you use a CDN, look at what it actually sends Safari.
Keeping JPEG and pushing its quality down until the bytes matched was the cheapest option. The sunset sky in the hero photo would have shown it first.
What the browser didn't tell me
Two problems came from the server side, and neither looked broken on the page.
The first was the content type. The host's Apache had no entry for the .avif extension, so every AVIF went out with no Content-Type at all. The page rendered fine. The response headers were the only place it showed. One line in .htaccess fixed it: AddType image/avif .avif.
The second was encode time. AVIF is slow to encode, and the encoder's effort setting trades time for size. The demo encodes its three small AVIFs at effort 5, a one-time cost because the files are committed. My blog script encodes every post's hero set, so it runs at effort 2, which encodes in about a tenth of the time of effort 4. It also skips files that already exist, so a build only encodes what's new.
And one surprise from the other direction: a high-density laptop screen asked for the 2,400-pixel AVIF, which at 184 KB undid much of the savings. I capped the hero srcset at 2,048 pixels.
Mobile came back at 99 after this pass and the others. The hero photo now arrives as AVIF for nearly everyone and as JPEG for the few who need it.
Offer Every Format and Let the Browser Choose
If I built the image pipeline again, the <picture> would be there from the first commit, with the JPEG as its last line. Every new file type would get its response headers checked the day it went in. And the hero's bytes would be the first thing I measured, because on a page led by a photo, that file is what the score waits on.
The browser knows what it can open. My job is to put every good option in front of it, in order, and let it pick.



