DEV Community

Cover image for My Images Were Silently Killing My SEO (And JavaScript Is Both the Cause and the Fix)
Mitu Das
Mitu Das

Posted on • Originally published at ccbd.dev

My Images Were Silently Killing My SEO (And JavaScript Is Both the Cause and the Fix)

I spent weeks perfecting my site's code, sweating over bundle sizes and Time to Interactive. Then Lighthouse ran and my performance score was 47. The culprit? A 3.2 MB hero image served as a PNG with no lazy loading, no alt text, and dimensions that existed nowhere in my HTML. Google noticed. My users noticed. I didn't.

Image optimization is one of the highest-ROI things I've done for web performance, and most of it can be automated entirely in JavaScript. Here's the practical, copy-paste pipeline I now use, covering the four things that actually moved the needle.

1. Lazy Loading: The Two-Line Win I Was Missing

Native lazy loading has been in browsers since 2019, but I still review codebases weekly where every image loads eagerly. Here's what that costs: every image above and below the fold fires on initial page load, competing for bandwidth with critical resources.

<!-- Before: loads everything immediately -->
<img src="product-photo.jpg" alt="Blue sneaker" />

<!-- After: defers off-screen images -->
<img src="product-photo.jpg" alt="Blue sneaker" loading="lazy" />
Enter fullscreen mode Exit fullscreen mode

That's it for static HTML. But if images are rendered dynamically in JavaScript, I do it like this from the start:

function createOptimizedImage({ src, alt, width, height }) {
  const img = document.createElement('img');
  img.src = src;
  img.alt = alt;
  img.loading = 'lazy';
  img.decoding = 'async'; // non-blocking decode
  if (width) img.width = width;
  if (height) img.height = height; // prevents layout shift (CLS)
  return img;
}

// Usage
const productImg = createOptimizedImage({
  src: '/images/sneaker-blue.jpg',
  alt: 'Blue sneaker, side view',
  width: 800,
  height: 600,
});
document.querySelector('.product-grid').appendChild(productImg);
Enter fullscreen mode Exit fullscreen mode

I always set explicit width and height now. Skipping them is the number one cause of Cumulative Layout Shift (CLS), a Core Web Vital that directly impacts search ranking.

Result: Pages with lazy loading + explicit dimensions routinely cut initial payload by 60-80% on image-heavy pages.

2. Format Conversion: WebP and AVIF Are Not Optional Anymore

JPEG and PNG are legacy formats. WebP delivers equivalent visual quality at 25-35% smaller file sizes. AVIF goes even further, often 50% smaller than JPEG. Both are supported in all modern browsers.

The right way to serve them is with the <picture> element, which lets the browser pick the best format it supports:

<picture>
  <source srcset="/images/hero.avif" type="image/avif" />
  <source srcset="/images/hero.webp" type="image/webp" />
  <img src="/images/hero.jpg" alt="Mountain landscape at dusk" width="1200" height="630" loading="lazy" />
</picture>
Enter fullscreen mode Exit fullscreen mode

To generate those formats as part of a build pipeline, I use Sharp, the fastest Node.js image processing library:

// build/optimize-images.js
import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';

async function convertToModernFormats(inputGlob) {
  const files = await glob(inputGlob);

  for (const file of files) {
    const dir = path.dirname(file);
    const name = path.basename(file, path.extname(file));
    const image = sharp(file);
    const meta = await image.metadata();

    // Skip if already small
    if (meta.width < 200) continue;

    // Generate WebP
    await image
      .webp({ quality: 80 })
      .toFile(path.join(dir, `${name}.webp`));

    // Generate AVIF (slower to encode, but worth it for static assets)
    await image
      .avif({ quality: 65 })
      .toFile(path.join(dir, `${name}.avif`));

    console.log(`✓ Converted: ${file}`);
  }
}

convertToModernFormats('public/images/**/*.{jpg,jpeg,png}');
Enter fullscreen mode Exit fullscreen mode

I wire this into my package.json build step:

{
  "scripts": {
    "build:images": "node build/optimize-images.js",
    "build": "npm run build:images && vite build"
  }
}
Enter fullscreen mode Exit fullscreen mode

Result: On a recent e-commerce project, this alone dropped total image transfer from 8.4 MB to 3.1 MB, a 63% reduction with no visible quality loss.

3. Responsive Images: I Was Serving 2400px Images to Mobile

Even with WebP, serving a 2400px-wide image to a 390px mobile screen wastes ~90% of the bytes. The srcset and sizes attributes fix this:

<img
  srcset="
    /images/hero-400.webp   400w,
    /images/hero-800.webp   800w,
    /images/hero-1200.webp 1200w,
    /images/hero-2400.webp 2400w
  "
  sizes="
    (max-width: 600px)  100vw,
    (max-width: 1200px) 80vw,
    1200px
  "
  src="/images/hero-1200.webp"
  alt="Hero image"
  width="1200"
  height="630"
  loading="lazy"
/>
Enter fullscreen mode Exit fullscreen mode

I generate those multiple sizes automatically with Sharp:

// build/generate-responsive.js
import sharp from 'sharp';

const BREAKPOINTS = [400, 800, 1200, 1920, 2400];

async function generateResponsiveSizes(inputPath) {
  const image = sharp(inputPath);
  const meta = await image.metadata();
  const name = inputPath.replace(/\.[^.]+$/, '');

  for (const width of BREAKPOINTS) {
    // Don't upscale
    if (width > meta.width) continue;

    await image
      .resize(width)
      .webp({ quality: 80 })
      .toFile(`${name}-${width}.webp`);
  }

  console.log(`✓ Responsive sizes generated for: ${inputPath}`);
}

generateResponsiveSizes('public/images/hero.jpg');
Enter fullscreen mode Exit fullscreen mode

Result: Mobile users download the right-sized image, not a desktop image scaled down by CSS. A 2400px hero image at ~450 KB becomes a 400px version at ~28 KB for mobile, a 16x difference.

4. SEO Metadata: What Google Actually Reads

Page speed is only half the equation. Google also reads alt text, structured data, and Open Graph tags to understand what images are. Getting this right matters for image search ranking and rich results.

For structured data (which powers rich snippets), I inject JSON-LD into the page head. This works programmatically in any framework:

// utils/seo-metadata.js
export function buildImageStructuredData({ url, contentUrl, description, width, height }) {
  return {
    '@context': 'https://schema.org',
    '@type': 'ImageObject',
    url,
    contentUrl,
    description,
    width,
    height,
  };
}

export function injectStructuredData(data) {
  const script = document.createElement('script');
  script.type = 'application/ld+json';
  script.textContent = JSON.stringify(data);
  document.head.appendChild(script);
}

// Usage
injectStructuredData(
  buildImageStructuredData({
    url: 'https://yoursite.com/blog/sneaker-review',
    contentUrl: 'https://yoursite.com/images/sneaker-blue-800.webp',
    description: 'Blue Nike Air Max in profile view on white background',
    width: 800,
    height: 600,
  })
);
Enter fullscreen mode Exit fullscreen mode

For Open Graph (controls how a page looks when shared on social), I add these meta tags server-side or in my framework's head component:

<meta property="og:image" content="https://yoursite.com/images/hero-1200.webp" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Descriptive alt text here" />
Enter fullscreen mode Exit fullscreen mode

For sites with lots of dynamic content, I've been using the @power-seo npm package to handle structured data generation, Open Graph injection, and canonical tag management in one place. It cut out a lot of boilerplate I was copying between pages. There's a deeper breakdown of what it automates in this guide on javascript image optimization.

What I Learned (The Hard Way)

  • Explicit width and height on every <img> are non-negotiable. CLS from missing dimensions tanked my Core Web Vitals score more reliably than almost anything else.

  • Run the image build step before, not after, the JS build. When my bundler processed HTML before WebP files existed, I silently served broken <source> tags in production for two weeks.

  • alt text is dual-purpose. It's accessibility and an SEO signal. I write it for the user first, but I don't skip it. Images with empty or missing alt are invisible to Google Image Search.

  • Automate everything in a build script. Manual optimization doesn't survive a deadline. A one-time build:images script running in CI means I never have to think about it again, and neither does anyone else on the team.

What's Your Biggest Image Headache?

Is it the build pipeline? Getting the team to adopt modern formats? Or is it the SEO side, figuring out what Google actually needs to index images properly?

Drop a comment. I'm especially curious whether anyone is using AVIF in production yet, or still holding back because of encode time. The tradeoffs are real and worth talking about.

Top comments (0)