JavaScript Image Optimization: How I Fixed a Lighthouse Score That Was Quietly Destroying My Rankings

I wrote clean JavaScript. My Lighthouse score still read 42. I stared at the waterfall and saw it: a 3.8 MB hero image loaded eagerly before a single line of CSS rendered. JavaScript image optimization is responsible for fixing more than 50% of page weight issues on the average site, yet most developers treat it as an afterthought, something to "handle later." In this article, I'll show exactly how to stop doing that, with real, copy-paste code that moved my LCP from the red into the green.
Why JavaScript Image Optimization Directly Affects Rankings
Google's Core Web Vitals include Largest Contentful Paint (LCP), the time until the biggest visible element renders. In the vast majority of cases, that element is an image. Google's own data shows that pages with LCP under 2.5 seconds rank measurably better in mobile search results.
But here's the counterintuitive part: the problem usually isn't the image file, it's how JavaScript handles it. Lazy loading too aggressively, serving JPEG when WebP/AVIF is supported, and skipping width/height attributes that cause layout shifts these are engineering mistakes, not design ones. Proper JavaScript image optimization targets all three.
I've fixed them systematically across several projects. Here's what works.
1. Serve the Right Format With a JavaScript Feature-Detection Snippet
The first step in any JavaScript image optimization strategy is format selection. Most developers still default to JPEG everywhere. Browsers that support WebP (94%+ of global users) and AVIF (87%+) can render the same image at 30–50% smaller file size with zero visual difference. Here's a zero-dependency utility that detects support and serves the best format automatically:
// utils/imageFormat.js
const supportsFormat = (() => {
const cache = {};
return async (format) => {
if (format in cache) return cache[format];
const img = new Image();
const testSrcs = {
avif: "data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKBzgA",
webp: "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA",
};
return new Promise((resolve) => {
img.onload = () => { cache[format] = true; resolve(true); };
img.onerror = () => { cache[format] = false; resolve(false); };
img.src = testSrcs[format];
});
};
})();
export async function getBestImageSrc(basePath) {
// basePath: "/images/hero" no extension
if (await supportsFormat("avif")) return `${basePath}.avif`;
if (await supportsFormat("webp")) return `${basePath}.webp`;
return `${basePath}.jpg`;
}
Usage:
import { getBestImageSrc } from "./utils/imageFormat.js";
const src = await getBestImageSrc("/images/hero");
document.querySelector("#hero-img").src = src; // → hero.avif on Chrome, hero.jpg on IE11
Result: Zero extra HTTP requests for format negotiation. The right format loads immediately on the first request, cutting image payload by up to half.
2. Smart Lazy Loading: The JavaScript Image Optimization Mistake I See Constantly
This is where JavaScript image optimization goes wrong most often. Developers apply loading="lazy" to every image on the page, including the above-the-fold hero, and then wonder why LCP tanks.
The rule I follow is simple: never lazy load the first visible image. Lazy load everything below the fold. Here's a script that handles this automatically based on viewport position:
// utils/smartLazyLoad.js
export function applySmartLazyLoading(containerSelector = "main") {
const container = document.querySelector(containerSelector);
if (!container) return;
const images = [...container.querySelectorAll("img")];
const viewportHeight = window.innerHeight;
images.forEach((img) => {
const rect = img.getBoundingClientRect();
const isAboveFold = rect.top < viewportHeight;
if (isAboveFold) {
// Critical image: eager load, high fetchpriority
img.loading = "eager";
img.fetchPriority = "high";
} else {
// Below fold: lazy load
img.loading = "lazy";
img.decoding = "async";
}
});
}
Usage:
document.addEventListener("DOMContentLoaded", () => {
applySmartLazyLoading("main");
});
Result: The hero image loads immediately. Everything below the fold defers. LCP improves without any build-step changes.
3. Prevent Layout Shift: The Hidden CLS Problem in JavaScript Image Optimization
Cumulative Layout Shift (CLS), the metric that measures how much a page jumps while loading, is heavily image-driven. When <img> tags lack width and height, the browser has no idea how much space to reserve before the image downloads. I've watched a page's CLS score crater just from a few unattributed images. This JavaScript image optimization step is the easiest to overlook and the fastest to fix:
// utils/preserveAspectRatio.js
export async function setNativeDimensions(imgSelector = "img[data-src]") {
const images = document.querySelectorAll(imgSelector);
const setDimensions = (img) =>
new Promise((resolve) => {
const temp = new Image();
temp.onload = () => {
if (!img.getAttribute("width")) img.setAttribute("width", temp.naturalWidth);
if (!img.getAttribute("height")) img.setAttribute("height", temp.naturalHeight);
resolve();
};
temp.src = img.dataset.src || img.src;
});
await Promise.all([...images].map(setDimensions));
}
Pair this with one CSS rule that locks the aspect ratio:
img {
height: auto; /* Always pair with explicit width/height attributes */
}
Result: Zero layout jumps during page load. CLS score drops to near 0 for image-heavy pages.
4. Connecting JavaScript Image Optimization to SEO Metadata
The three utilities above cover the rendering side, but JavaScript image optimization doesn't stop at the browser. Crawlers read <meta> tags, Open Graph images, and structured data and if those point to unoptimized sources, the SEO signal is incomplete. Wiring all of this together across a large site gets repetitive fast.
While building a series of SEO tooling experiments, I needed something that bundled image optimization signals together with broader metadata management. That's how power-seo came together. It handles image alt tags, Open Graph image declarations, and structured data image references in one pass:
import PowerSEO from "power-seo";
const seo = new PowerSEO({
title: "My Article Title",
description: "A genuine meta description.",
image: {
src: "/images/hero.avif",
alt: "A descriptive alt text for screen readers and crawlers",
width: 1200,
height: 630,
},
});
seo.apply(); // Writes <meta> tags, OG tags, and JSON-LD in one call
It's one option there are others like next/head, react-helmet, and native platform APIs. The point is that JavaScript image optimization and metadata have to move together, or crawlers receive contradictory signals about the page.
Key Takeaways: JavaScript Image Optimization That Actually Moves Rankings
Never lazy load above-the-fold images.
loading="lazy"on a hero image is actively hurting LCP score. I treat it as a bug now.Format negotiation is pure JavaScript. A 20-line feature detection snippet handles AVIF/WebP fallback without CDN or infrastructure changes.
Width and height attributes are not optional. Omitting them is the most common source of high CLS scores in JavaScript image optimization work. I always set them, even when using CSS for visual sizing.
Optimization and metadata are the same problem. If
<meta property="og:image">points to a 4 MB JPEG while the page serves AVIF, it's optimizing for users but not for crawlers. Keep them in sync.
If you want to explore the metadata side of JavaScript image optimization, the repo is here: https://github.com/CyberCraftBD/power-seo
Let's Talk
What's the biggest Core Web Vitals bottleneck right now LCP, CLS, or INP? I've seen teams spend weeks on JavaScript bundle size while a single unoptimized image is doing all the damage. Drop a Lighthouse score in the comments happy to dig into what's dragging it down.





