Front-end Engineering Lab

Progressive Image Loading

Blur-up technique and other strategies for smooth image loading

Progressive Image Loading

Progressive image loading shows a low-quality version instantly, then smoothly transitions to high-quality. This creates the perception of instant loading while the full image downloads in the background.

Blur-Up Technique

The blur-up technique loads a tiny, blurred placeholder first, then replaces it with the full image.

Basic Implementation

function ProgressiveImage({ 
  src, 
  placeholder,
  alt,
}: Props) {
  const [loaded, setLoaded] = useState(false);
  const [currentSrc, setCurrentSrc] = useState(placeholder);

  useEffect(() => {
    const img = new window.Image();
    img.src = src;
    img.onload = () => {
      setCurrentSrc(src);
      setLoaded(true);
    };
  }, [src]);

  return (
    <img
      src={currentSrc}
      alt={alt}
      style={{
        filter: loaded ? 'blur(0)' : 'blur(20px)',
        transition: 'filter 0.3s ease-out',
        transform: loaded ? 'scale(1)' : 'scale(1.05)', // Slight zoom to hide blur edges
        transition: 'all 0.3s ease-out',
      }}
    />
  );
}

// Usage
<ProgressiveImage
  src="/hero-full.jpg"
  placeholder="/hero-tiny.jpg"  // 20x15px
  alt="Hero"
/>

With Intersection Observer

function LazyProgressiveImage({ src, placeholder, alt }: Props) {
  const [currentSrc, setCurrentSrc] = useState(placeholder);
  const [loaded, setLoaded] = useState(false);
  const [inView, setInView] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);

  // Detect when image enters viewport
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setInView(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }  // Start loading 200px before visible
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

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

  // Load full image when in view
  useEffect(() => {
    if (!inView) return;

    const img = new window.Image();
    img.src = src;
    img.onload = () => {
      setCurrentSrc(src);
      setLoaded(true);
    };
  }, [inView, src]);

  return (
    <img
      ref={imgRef}
      src={currentSrc}
      alt={alt}
      style={{
        filter: loaded ? 'blur(0)' : 'blur(20px)',
        transform: loaded ? 'scale(1)' : 'scale(1.05)',
        transition: 'all 0.3s ease-out',
      }}
    />
  );
}

Multi-Stage Loading

Load increasingly higher quality versions.

interface ImageStage {
  src: string;
  quality: 'thumbnail' | 'low' | 'medium' | 'high';
}

function MultiStageImage({ stages, alt }: Props) {
  const [currentStage, setCurrentStage] = useState(0);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    if (currentStage >= stages.length) return;

    const img = new window.Image();
    img.src = stages[currentStage].src;
    
    img.onload = () => {
      setLoaded(true);
      
      // Load next stage after transition
      if (currentStage < stages.length - 1) {
        setTimeout(() => {
          setCurrentStage(prev => prev + 1);
          setLoaded(false);
        }, 300);
      }
    };
  }, [currentStage, stages]);

  return (
    <img
      src={stages[currentStage].src}
      alt={alt}
      style={{
        filter: loaded ? 'blur(0)' : 'blur(10px)',
        opacity: loaded ? 1 : 0.8,
        transition: 'all 0.3s ease-out',
      }}
    />
  );
}

// Usage
<MultiStageImage
  stages={[
    { src: '/image-thumb.jpg', quality: 'thumbnail' },  // 20px
    { src: '/image-low.jpg', quality: 'low' },          // 400px
    { src: '/image-medium.jpg', quality: 'medium' },    // 800px
    { src: '/image-high.jpg', quality: 'high' },        // 1920px
  ]}
  alt="Product"
/>

Base64 Inline Placeholder

Embed tiny image directly in HTML (no extra request).

// Generate base64 placeholder (build time)
import sharp from 'sharp';

async function generateBase64Placeholder(imagePath: string) {
  const buffer = await sharp(imagePath)
    .resize(20)
    .jpeg({ quality: 20 })
    .toBuffer();
  
  return `data:image/jpeg;base64,${buffer.toString('base64')}`;
}

// Use in component
function ImageWithBase64({ src, base64Placeholder, alt }: Props) {
  const [loaded, setLoaded] = useState(false);
  const [currentSrc, setCurrentSrc] = useState(base64Placeholder);

  useEffect(() => {
    const img = new window.Image();
    img.src = src;
    img.onload = () => {
      setCurrentSrc(src);
      setLoaded(true);
    };
  }, [src]);

  return (
    <img
      src={currentSrc}
      alt={alt}
      style={{
        filter: loaded ? 'blur(0)' : 'blur(20px)',
        transition: 'filter 0.3s',
      }}
    />
  );
}

// Usage (base64 is inline, no request!)
<ImageWithBase64
  src="/hero.jpg"
  base64Placeholder="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
  alt="Hero"
/>

Next.js Automatic Blur-Up

import Image from 'next/image';
import heroImage from './hero.jpg'; // Local import

// Next.js generates placeholder automatically
<Image
  src={heroImage}
  alt="Hero"
  placeholder="blur"  // Automatic blur-up!
  priority
/>

// For remote images, provide blurDataURL
<Image
  src="https://example.com/image.jpg"
  alt="Remote"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

Progressive JPEG

Progressive JPEGs load in multiple scans (increasing quality).

# Convert to progressive JPEG with ImageMagick
convert input.jpg -interlace Plane output.jpg

# With Sharp (Node.js)
sharp('input.jpg')
  .jpeg({ progressive: true, quality: 85 })
  .toFile('output.jpg');

# With mozjpeg (best compression + progressive)
sharp('input.jpg')
  .jpeg({ progressive: true, quality: 85, mozjpeg: true })
  .toFile('output.jpg');

Benefits

Baseline JPEG:
- Loads top-to-bottom
- Blocked view until fully loaded

Progressive JPEG:
- Loads in multiple passes (increasing quality)
- Low-quality version visible quickly
- Perceived performance boost
- Same file size or smaller

Srcset for Responsive Loading

Load appropriate size based on viewport.

<img
  srcSet="
    /image-320w.jpg 320w,
    /image-640w.jpg 640w,
    /image-1280w.jpg 1280w,
    /image-1920w.jpg 1920w
  "
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
  src="/image-1280w.jpg"
  alt="Responsive image"
  loading="lazy"
/>

BlurHash Implementation

BlurHash creates a colorful placeholder from an image hash.

npm install blurhash react-blurhash
// Generate blurhash (server-side)
import { encode } from 'blurhash';
import sharp from 'sharp';

export async function generateBlurHash(imagePath: string) {
  const { data, info } = await sharp(imagePath)
    .raw()
    .ensureAlpha()
    .resize(32, 32, { fit: 'inside' })
    .toBuffer({ resolveWithObject: true });

  const blurHash = encode(
    new Uint8ClampedArray(data),
    info.width,
    info.height,
    4,  // componentX
    4   // componentY
  );

  return { blurHash, width: info.width, height: info.height };
}

// Use in component
import { Blurhash } from 'react-blurhash';

function ImageWithBlurHash({ src, hash, width, height, alt }: Props) {
  const [loaded, setLoaded] = useState(false);

  return (
    <div style={{ position: 'relative', width, height }}>
      {/* BlurHash placeholder */}
      {!loaded && (
        <Blurhash
          hash={hash}
          width={width}
          height={height}
          resolutionX={32}
          resolutionY={32}
          punch={1}
          style={{ position: 'absolute' }}
        />
      )}
      
      {/* Final image */}
      <img
        src={src}
        alt={alt}
        onLoad={() => setLoaded(true)}
        style={{
          opacity: loaded ? 1 : 0,
          transition: 'opacity 0.3s',
        }}
      />
    </div>
  );
}

// Usage
<ImageWithBlurHash
  src="/image.jpg"
  hash="LGF5]+Yk^6#M@-5c,1J5@[or[Q6."
  width={800}
  height={600}
  alt="Product"
/>

Custom Hook

function useProgressiveImage(src: string, placeholder: string) {
  const [currentSrc, setCurrentSrc] = useState(placeholder);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const img = new window.Image();
    
    img.onload = () => {
      setCurrentSrc(src);
      setLoading(false);
    };
    
    img.onerror = () => {
      setError(new Error('Failed to load image'));
      setLoading(false);
    };
    
    img.src = src;
  }, [src]);

  return { src: currentSrc, loading, error };
}

// Usage
function ProductImage({ fullSrc, placeholderSrc, alt }: Props) {
  const { src, loading, error } = useProgressiveImage(fullSrc, placeholderSrc);

  if (error) {
    return <div>Failed to load image</div>;
  }

  return (
    <img
      src={src}
      alt={alt}
      style={{
        filter: loading ? 'blur(20px)' : 'blur(0)',
        transition: 'filter 0.3s',
      }}
    />
  );
}

Fade Transition

function FadeInImage({ src, placeholder, alt }: Props) {
  const [imgSrc, setImgSrc] = useState(placeholder);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const img = new window.Image();
    img.src = src;
    img.onload = () => {
      setImgSrc(src);
      setIsLoading(false);
    };
  }, [src]);

  return (
    <div style={{ position: 'relative' }}>
      {/* Placeholder */}
      <img
        src={placeholder}
        alt=""
        style={{
          position: 'absolute',
          filter: 'blur(20px)',
          opacity: isLoading ? 1 : 0,
          transition: 'opacity 0.3s',
        }}
      />
      
      {/* Final image */}
      <img
        src={imgSrc}
        alt={alt}
        style={{
          opacity: isLoading ? 0 : 1,
          transition: 'opacity 0.3s',
        }}
      />
    </div>
  );
}

Performance Considerations

Optimize Placeholder Size

Thumbnail size: 20-40px width
Quality: 20-30%
Format: JPEG (smallest)
Base64: ~1-2 KB

Goal: < 2 KB per placeholder

Generate at Build Time

// next.config.js with sharp
const sharp = require('sharp');

async function generatePlaceholders(images) {
  return Promise.all(
    images.map(async (img) => {
      const buffer = await sharp(img.path)
        .resize(20)
        .jpeg({ quality: 20 })
        .toBuffer();
      
      return {
        ...img,
        placeholder: `data:image/jpeg;base64,${buffer.toString('base64')}`,
      };
    })
  );
}

Cache Placeholders

// Cache placeholders in memory
const placeholderCache = new Map<string, string>();

async function getPlaceholder(src: string) {
  if (placeholderCache.has(src)) {
    return placeholderCache.get(src)!;
  }

  const placeholder = await generatePlaceholder(src);
  placeholderCache.set(src, placeholder);
  return placeholder;
}

Best Practices

  1. Small Placeholders: 20-40px wide, ~1-2 KB
  2. Smooth Transitions: 300ms ease-out
  3. Progressive JPEGs: For large images
  4. Inline Base64: For above-fold images
  5. Lazy Load: Below-fold images
  6. Srcset: Responsive image sizes
  7. Preload: Critical images
  8. Cache: Store placeholders
  9. Error Handling: Fallback for failed loads
  10. Accessibility: Alt text always

Common Pitfalls

Large placeholders: Defeats purpose
Tiny (20px), optimized (<2 KB)

No transition: Jarring pop-in
Smooth blur-to-sharp transition

Runtime generation: Slow
Generate at build time

Loading all sizes: Wastes bandwidth
Srcset + sizes for responsive

Blocking render: Waiting for images
Show placeholder immediately

Progressive loading creates the illusion of instant images—master this technique!

On this page