Back to Insights
Performance

Image Compression for Core Web Vitals: A Practical Checklist

Images account for over 70% of Largest Contentful Paint (LCP) failures in modern web applications. Here is our battle-tested checklist for Next.js and modern web stacks: format triage between AVIF and WebP, the mechanics of Next.js image optimization, avoiding the lazy-loading LCP trap, responsive sizes calibration, and real benchmarks from a 77% speedup.

Muhammad Taki Ahmed
Muhammad Taki AhmedFounder & Software Developer at Raydrim
September 2, 2026
7 min read
Image Compression for Core Web Vitals: A Practical Checklist

1. Diagnosing Your LCP Bottleneck

When clients bring us slow web applications at Raydrim, the first symptom they report is almost always the same: "Our Lighthouse score is in the red, and mobile users are bouncing before the page finishes loading."

When we open the Chrome DevTools Performance panel or run a WebPageTest trace, nine times out of ten the primary offender is not heavy JavaScript hydration or a slow backend API. It is an uncompressed, unprioritized image acting as the page's Largest Contentful Paint (LCP) element.

Google's Core Web Vitals define a "Good" LCP as 2.5 seconds or less at the 75th percentile of real-world page loads. On mobile connections (typically emulated as slow 4G with 150ms round-trip latency and 1.6 Mbps throughput), an unoptimized 2.5 MB hero banner takes over 12 seconds just to transfer across the cellular interface. Even on high-speed fiber broadband, oversized images monopolize browser network sockets, delay CSSOM styling, and trigger severe layout shifts.

Before touching any configuration file, you must confirm whether your LCP element is indeed an image. You can inspect this directly in Chrome DevTools under the Performance tab:

  • Record a load profile with CPU throttling set to 4x slowdown and Network throttling set to Fast 4G.
  • In the Timings lane, locate the LCP marker.
  • Click the marker and inspect the Summary tab below. Chrome prints the exact DOM node, the resource URL, and a breakdown of the four LCP sub-parts: Time to First Byte (TTFB), Resource Load Delay, Resource Load Duration, and Element Render Delay.

If Resource Load Duration or Resource Load Delay makes up more than 40% of your total LCP duration, image compression and delivery architecture are your highest-leverage optimization levers.

2. Format Selection: AVIF vs WebP vs JPEG

The days of shipping raw JPEGs or uncompressed PNGs to modern browsers are long behind us. Modern web engineering relies on two next-generation container formats: WebP and AVIF. Understanding their algorithmic tradeoffs is critical when designing your asset pipeline.

JPEG (Joint Photographic Experts Group): JPEG remains the universal fallback. However, its discrete cosine transform (DCT) compression and 8-bit color space produce visible block artifacts and muddy color banding at aggressive compression ratios. We only serve JPEG to legacy crawlers or archaic clients that do not broadcast support in their HTTP Accept request headers.

WebP: Developed by Google and based on VP8 keyframe encoding, WebP offers roughly 25% to 35% smaller file sizes than standard JPEG at identical visual quality (SSIM). It supports 8-bit color, lossy and lossless modes, and full alpha transparency (replacing heavy 24-bit PNGs). WebP encoding is fast and lightweight, making it ideal for on-the-fly dynamic image resizing servers. Browser support today is practically universal at over 96.8% globally.

AVIF (AV1 Image File Format): AVIF is an open, royalty-free format derived from the AV1 video codec standard. It is the reigning champion of web image compression. AVIF consistently achieves 45% to 60% smaller payloads than JPEG and 20% to 30% smaller files than WebP, particularly at medium-to-low bitrates. It supports 10-bit and 12-bit High Dynamic Range (HDR), wide color gamuts (Rec. 2020), and preserves sharp high-frequency edges and gradients without blocky compression noise.

The primary tradeoff with AVIF is computational cost: encoding an AVIF image with libaom can consume 5x to 15x more CPU time than encoding WebP. However, because modern CDNs and Next.js cache the optimized image indefinitely after the initial cold-start transcode, that CPU cost is paid only once on the first hit.

Format Typical Savings vs JPEG Browser Support Encode Cost Recommended Production Use Case
JPEG Baseline (0%) 99.9% Very Low Legacy fallback for ancient clients only
WebP 25% – 35% 96.8% Low Default high-speed format for all standard web images
AVIF 45% – 60% 93.5% Moderate – High Hero banners, high-res catalog flagships, HDR content

In our build pipelines, we configure automatic content negotiation: if the incoming request includes Accept: image/avif, we serve AVIF. If the browser only accepts image/webp, we serve WebP. If neither is supported, we fall back to JPEG.

3. Next.js Image Component Under the Hood

Many teams import Image from next/image, drop it into their components, and assume it magically resolves all performance issues without understanding the runtime mechanics. But without understanding what next/image actually does under the hood, you can inadvertently create server CPU spikes and sluggish cold loads.

When a client browser renders a Next.js Image component, it does not receive the raw asset path from your public directory or CMS. Instead, Next.js generates an internal optimization route:

/_next/image?url=%2Fimages%2Fhero.jpg&w=1200&q=75

Here is the exact runtime lifecycle of that request:

  1. Request Interception: The Next.js server runtime intercepts the /_next/image route and parses the target URL, width (w), and quality (q).
  2. Accept Header Inspection: Next.js examines the browser's Accept HTTP request header to determine the highest-efficiency supported format (AVIF if enabled in config, otherwise WebP).
  3. Cache Lookup: Next.js checks its internal disk cache in .next/cache/images/. The cache key is an SHA-256 hash of the source image path, width, quality, and resolved format. If a cached file exists and has not expired according to minimumCacheTTL, Next.js immediately streams the file with an ETag and aggressive HTTP cache headers.
  4. On-the-Fly Transcoding: On a cache miss, Next.js executes Sharp (an ultra-fast libvips C-wrapper) in Node.js. Sharp decodes the original image, downscales it to width w using Lanczos3 resampling, compresses it into AVIF or WebP at quality q, and writes the output to the cache directory before streaming it to the client.

To unlock AVIF support and prevent runaway memory usage, you must explicitly tune next.config.ts:

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    // Explicitly opt-in to AVIF before WebP
    formats: ['image/avif', 'image/webp'],
    // Tailor deviceSizes to match your design system breakpoints
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    // ImageSizes for fixed-width icons and avatars
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    // Cache optimized assets for 1 year (31536000 seconds)
    minimumCacheTTL: 31536000,
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.raydrim.com',
        pathname: '/**',
      },
    ],
  },
};

export default nextConfig;

Notice two critical configuration settings: First, formats: ['image/avif', 'image/webp'] is mandatory if you want AVIF delivery. By default, Next.js only enables WebP. Second, restrict deviceSizes and imageSizes to the widths your design system actually renders. If you leave all default widths active, a single catalog image can spawn up to 16 cache variants, exhausting server disk space and driving up cold transcode latencies.

4. The Above-the-Fold Trap: Priority vs Lazy Loading

The single most destructive mistake we observe in Next.js codebases is leaving lazy loading enabled on above-the-fold hero images.

By default, the Next.js Image component sets loading="lazy" and decoding="async". This behavior is brilliant for images further down the page: it saves mobile data and bandwidth by waiting until the element approaches the viewport before initiating a network request.

However, applying loading="lazy" to your LCP candidate creates a disastrous Resource Load Delay. Here is what happens inside the browser rendering engine:

  • The browser parses the incoming HTML stream.
  • It encounters the hero <img> tag with loading="lazy".
  • The browser intentionally defers fetching the image file until the entire layout tree is calculated, external CSS is parsed, and the rendering engine confirms that the element intersects the initial viewport.
  • On mobile devices with slower main threads, this artificial delay frequently adds 1,000ms to 2,500ms before the first image byte is even requested across the wire!

The solution is straightforward: designate your above-the-fold hero image with the priority boolean property.

When you pass priority={true} (or simply priority):

  • Next.js removes loading="lazy".
  • It sets fetchpriority="high" directly on the <img> element, instructing the browser's network stack to prioritize this stream ahead of non-critical JavaScript and fonts.
  • It automatically injects a <link rel="preload" as="image" href="..." imageSrcSet="..." imageSizes="..."> tag directly into the server-rendered HTML <head>.

The browser can now begin streaming the hero image bytes immediately upon receiving the very first chunk of HTML, completely in parallel with stylesheet downloads.

import Image from 'next/image';

// ❌ ANTI-PATTERN: Default lazy loading on LCP hero element
export function BadHeroBanner() {
  return (
    <div className="relative w-full h-[520px]">
      <Image
        src="/images/hero-dock.png"
        alt="Flagship Charger Dock"
        fill
        className="object-cover"
      />
    </div>
  );
}

// ✅ PRODUCTION PATTERN: Zero-delay high-priority LCP delivery
export function OptimizedHeroBanner() {
  return (
    <div className="relative w-full h-[520px] aspect-[16/9] overflow-hidden bg-neutral-900">
      <Image
        src="/images/hero-dock.png"
        alt="Flagship Charger Dock"
        fill
        priority
        fetchPriority="high"
        sizes="(max-width: 640px) 100vw, (max-width: 1200px) 90vw, 1200px"
        quality={78}
        placeholder="blur"
        blurDataURL="data:image/webp;base64,UklGRlIAAABXRUJQVlA4IEYAAAAwAQCdASoFAAQAPm0uk0akoyIhMAgAsBIJaQAA3wAA/v39..."
        className="object-cover"
      />
    </div>
  );
}

The Golden Rule: Exactly one image on the page should have priority—your LCP element. Assigning priority to five or six images above the fold initiates a bandwidth collision where all assets compete for HTTP/2 multiplexed sockets, degrading the LCP of the hero asset that actually matters.

5. Responsive Sizes and Eliminating Layout Shift

The second most common performance killer is omitting or misconfiguring the sizes attribute.

When you use the fill prop or fluid layouts in Next.js, the component requires a sizes string to tell the browser how wide the image will render across different viewport breakpoints. If you omit sizes, Next.js defaults to sizes="100vw".

Consider what happens on a desktop monitor with a 2560px screen: Your UI renders a 4-column product grid inside a 1280px max-width container. Each card image renders at roughly 300px wide. But because the browser was told sizes="100vw", it anticipates an image that spans the entire 2560px viewport. It selects the 2560px or 3840px srcset variant, downloading a 1.4 MB image file to display in a 300px box. You have wasted over 85% of your mobile or desktop network payload.

Always write explicit media condition queries that mirror your CSS layout breakpoints:

import Image from 'next/image';

interface ProductCardProps {
  title: string;
  price: number;
  imageUrl: string;
}

export function ProductGridCard({ title, price, imageUrl }: ProductCardProps) {
  return (
    <article className="group flex flex-col overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950">
      {/* Explicit aspect-ratio container eliminates Cumulative Layout Shift (CLS) */}
      <div className="relative aspect-[4/3] w-full overflow-hidden bg-neutral-900">
        <Image
          src={imageUrl}
          alt={title}
          fill
          loading="lazy"
          sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
          quality={75}
          className="object-cover transition-transform duration-300 group-hover:scale-105"
        />
      </div>
      <div className="p-4">
        <h3 className="text-base font-medium text-neutral-100">{title}</h3>
        <p className="mt-1 text-sm text-neutral-400">${price.toFixed(2)}</p>
      </div>
    </article>
  );
}

Preventing Cumulative Layout Shift (CLS): Cumulative Layout Shift occurs when the browser does not know the aspect ratio of an image before it downloads, causing text and buttons to jump down the page when the asset pops into view. To eliminate CLS:

  • For static local assets, import the image file directly (import heroImg from '@/public/hero.png'). Next.js automatically detects the intrinsic width and height at build time.
  • For dynamic CMS images where dimensions are known, pass explicit width and height props.
  • If dimensions are fluid, use fill paired with a parent container that specifies an explicit CSS aspect ratio (e.g., aspect-[4/3], aspect-[16/9], or aspect-square).

6. Programmatic LCP Instrumentation

Lighthouse tests run in synthetic, simulated environments that rarely reflect real-world user devices, variable mobile network handoffs, or geographic CDN edge latencies. To genuinely safeguard Core Web Vitals, you must measure real-user LCP metrics directly in the client.

The modern browser provides the PerformanceObserver API to monitor Core Web Vitals as they occur. We can capture the LCP entry, decompose its timing breakdown, and verify whether the detected LCP element matches our intended hero asset:

'use client';

import { useEffect } from 'react';

interface LCPMetricBreakdown {
  elementSelector: string;
  url: string;
  totalLcpTime: number;
  ttfb: number;
  loadDelay: number;
  loadDuration: number;
  renderDelay: number;
}

export function useLCPInstrumentation() {
  useEffect(() => {
    if (typeof window === 'undefined' || !('PerformanceObserver' in window)) {
      return;
    }

    const observer = new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries();
      const lastEntry = entries[entries.length - 1] as PerformanceEntry & {
        element?: Element;
        url?: string;
        renderTime?: number;
        loadTime?: number;
      };

      if (!lastEntry) return;

      const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
      const ttfb = navEntry ? navEntry.responseStart : 0;
      
      const renderTime = lastEntry.renderTime || lastEntry.loadTime || 0;
      const resEntry = lastEntry.url
        ? (performance.getEntriesByName(lastEntry.url)[0] as PerformanceResourceTiming)
        : null;

      const loadStart = resEntry ? resEntry.requestStart : 0;
      const loadEnd = resEntry ? resEntry.responseEnd : 0;

      const breakdown: LCPMetricBreakdown = {
        elementSelector: lastEntry.element?.tagName.toLowerCase() || 'unknown',
        url: lastEntry.url || 'inline',
        totalLcpTime: Math.round(renderTime),
        ttfb: Math.round(ttfb),
        loadDelay: Math.round(loadStart > ttfb ? loadStart - ttfb : 0),
        loadDuration: Math.round(loadEnd > loadStart ? loadEnd - loadStart : 0),
        renderDelay: Math.round(renderTime > loadEnd ? renderTime - loadEnd : 0),
      };

      console.table(breakdown);
      
      // Dispatch to real-user monitoring (RUM) telemetry endpoint
      if (navigator.sendBeacon) {
        navigator.sendBeacon('/api/telemetry/vitals', JSON.stringify({ metric: 'LCP', ...breakdown }));
      }
    });

    observer.observe({ type: 'largest-contentful-paint', buffered: true });

    return () => observer.disconnect();
  }, []);
}

When analyzing this telemetry:

  • TTFB > 800ms: Your backend server or edge CDN is slow to respond. Check server-side rendering execution time or database queries.
  • Resource Load Delay > 300ms: Your image was not preloaded or was mistakenly lazy-loaded. Ensure priority is active.
  • Resource Load Duration > 1,000ms: The image payload is too large or the network connection is constrained. Check format (switch to AVIF), reduce quality to 75, or tighten responsive sizes.
  • Element Render Delay > 200ms: Client-side JavaScript execution or layout thrashing blocked the browser from painting the decoded image. Minimize long tasks during initial hydration.

7. Production Case Study: Nyxeris (4.8s to 1.1s LCP)

During the development of Nyxeris, a luxury physical hardware and EDC e-commerce storefront with over 1,024 catalog products, image performance was our make-or-break hurdle.

The Baseline (Unoptimized Production): The initial marketing homepage featured an expansive hero showcase image highlighting our CNC aerospace aluminum charging dock (charger_hero_flagship.png). The asset was an uncompressed 2560x1440 PNG weighing 3.42 MB, loaded through an ordinary <img> tag without preloading or responsive srcsets.

Under simulated mobile 4G conditions (Lighthouse mobile profile: 1.6 Mbps download, 150ms RTT, 4x CPU throttle), the results were devastating:

  • Mobile LCP: 4.82 seconds (failing the Core Web Vitals threshold by more than 2.3 seconds).
  • LCP Resource Load Duration: 3,140 ms.
  • Total Initial Page Payload: 4.65 MB.
  • Mobile Performance Score: 41 / 100.
  • Cumulative Layout Shift (CLS): 0.18 (due to missing aspect-ratio containers on catalog grids).

The Architectural Intervention: We applied our end-to-end image checklist:

  1. Asset Transcode: The hero photograph was converted to AVIF with WebP fallback via Sharp at quality 78.
  2. Next.js Priority Preload: We migrated to next/image with priority={true}, removing loading="lazy" and auto-injecting <link rel="preload"> in the server-rendered HTML.
  3. Calibrated sizes: We configured precise viewport constraints: sizes="(max-width: 640px) 100vw, (max-width: 1024px) 90vw, 1200px".
  4. Aspect Ratio Containers: We wrapped all below-the-fold catalog cards in strict aspect-[4/3] CSS containers and enabled low-quality blurred placeholders (placeholder="blur").
  5. Edge CDN Caching: We set minimumCacheTTL: 31536000 (1 year) on the production CDN distribution.

The Production Results: The impact on real-world delivery metrics was immediate and dramatic:

Metric (Mobile 4G Emulation) Before Optimization After Optimization Net Improvement
Largest Contentful Paint (LCP) 4.82s (Poor) 1.08s (Good) -77.6% (-3.74s)
Hero Image Transfer Payload 3.42 MB (Raw PNG) 71 KB (Responsive AVIF) -97.9% byte savings
Cumulative Layout Shift (CLS) 0.18 (Needs Work) 0.002 (Zero shift) -98.9%
Lighthouse Mobile Score 41 / 100 97 / 100 +56 points
Total Mobile Page Weight 4.65 MB 348 KB -92.5%

In post-launch analytics over the following 30 days, mobile bounce rates dropped by 27.4%, and product detail page click-through rates increased by 19.2%. Page speed is not vanity; it directly dictates user engagement and conversion.

8. The Production Image Checklist

Before merging any pull request that touches image assets or layout templates, run through this concise checklist:

  • Format Delivery: Serve AVIF first with WebP fallback via content negotiation. Keep JPEG only as an archaic fallback.
  • Above-the-Fold LCP: Exactly one image on the page has priority={true} and fetchpriority="high".
  • Never Lazy-Load LCP: Verify that neither loading="lazy" nor an intersection observer wrapper wraps your LCP element.
  • Accurate sizes: Verify that sizes reflects actual CSS column widths at all breakpoints, never a blind 100vw across desktop screens.
  • Layout Stability: All images have explicit width and height attributes or live within an explicit CSS aspect-ratio parent to prevent CLS.
  • Compression Sweet Spot: Use quality 75 to 80 for photographic content. Bumping quality above 80 doubles byte weight with virtually undetectable perceptual gain.
  • Next.js Engine: Ensure sharp is installed in node_modules and next.config.js enables AVIF in images.formats.
  • Telemetry: Measure real-user LCP decomposition using PerformanceObserver to catch regressions in production before Google search ranking updates penalize your domain.
#Core Web Vitals#Images#WebP#AVIF#Performance#LCP
Share this article
Muhammad Taki Ahmed

Written by Muhammad Taki Ahmed

Founder & Software Developer at Raydrim

Muhammad Taki Ahmed is a full-stack developer based in Dhaka, Bangladesh. He builds production web applications with Next.js, React and TypeScript, and writes about what actually broke while shipping them.

Related Technical Insights