TLDR: Don't put
loading="lazy"on your LCP element. Lazy-load everything below it, and serve the hero/LCP image via your CDN's transform API — pre-sized, right format, no guessing.
The Setup
We were deep in a KOL CRM for an ecommerce business — a relationship management app for tracking key opinion leaders (KOL — the doctors and thought leaders an ecommerce business works with on webinars and campaigns).
Every KOL tile on the board showed a profile photo pulled straight from Supabase Storage (Supabase, our Postgres + file storage backend).
Raw storage URL. Full resolution. No transform. Every card loading its image at the same time.
Predictably… LCP (Largest Contentful Paint, Google's primary load-speed metric) was rough.
What I Tried First (Wrong)
My first instinct was the obvious move: slap loading="lazy" on every image.
Done in about 90 seconds. Felt good.
Felt good right until I checked the LCP score and it had gotten worse.
Here's the thing I had glossed over: loading="lazy" tells the browser "don't bother fetching this until the user scrolls near it." That's perfect for images below the fold.
It's a disaster for the first visible image on the page — the one Google literally uses as the LCP element.
I had deferred the very metric I was trying to move. Classic.
What Actually Worked
Two changes. One commit.
1. Lazy-load only below-the-fold images.
The KOL board renders a grid of tiles. The first row is visible on load — those stay eager. Everything below gets loading="lazy" and decoding="async".
The LCP element loads immediately. The browser queues the rest lazily, behind the fold. That's the whole trick.
2. Serve transformed, sized images through Supabase's CDN.
Raw storage URLs were serving whatever resolution was uploaded — sometimes huge. Supabase's image transform API lets you append ?width=200&quality=75&format=webp to any storage URL and get back a correctly-sized WebP, served from their CDN edge.
So instead of loading a 1.2MB JPEG to render a 100px avatar tile, we're now fetching a 12KB WebP.
The LCP image was no longer lazy. It was also no longer enormous.
I did this same thing a few weeks later on my own blog — a rehype plugin that walks every article image and injects loading="lazy" + decoding="async" at build time. The PNGs I'd migrated in were up to 1MB each. That felt embarrassing in retrospect.
Why This Matters
Image performance lives in two levers, and they pull in opposite directions if you're not paying attention:
- Above the fold: load eagerly, serve a small pre-sized file via CDN transform
- Below the fold: load lazily so it doesn't compete with the things the user actually sees first
Most advice just says "add lazy loading" — which is true for 95% of your images. But the one image that IS your LCP? Lazy loading it is like turning off the lights on your load time.
If you're using Supabase Storage, the transform API is already there. You're probably just not using it.
Start there.
P.S. If you're serving raw storage URLs anywhere in production, fix that first. The transform API is free and the difference is immediate.