# Progressive Image Loading From Scratch

Large images can make a page feel slow even when the rest of the interface loads quickly.

![](https://cdn.hashnode.com/uploads/covers/685a6d69d4d0ea366852534c/d384d2ed-ff63-4da9-bcc2-7ae158266073.png align="center")

**Progressive image loading** improves perceived performance by showing a lightweight placeholder first and replacing it with the full-quality image once it has downloaded and decoded.

The typical flow looks like this:

```text
Tiny Placeholder
      ↓
Full Image Downloads
      ↓
Image Decodes
      ↓
Smooth Fade-In
```

In this tutorial, we will build that behavior using plain HTML, CSS, and JavaScript.

* * *

## What Is Progressive Image Loading?

Progressive image loading does not necessarily make the final image file smaller. Instead, it improves the loading experience.

Without progressive loading:

```text
Blank Area
    ↓
Wait
    ↓
Large Image Appears
```

With progressive loading:

```text
Small Blurred Preview
        ↓
Full Image Loads
        ↓
Sharp Image Replaces Preview
```

This approach is sometimes called the **blur-up technique**.

It works especially well for:

*   Blog images
    
*   Product galleries
    
*   Portfolio pages
    
*   Image-heavy landing pages
    
*   Ecommerce cards
    

* * *

## Step 1: Create the HTML

Start with a wrapper containing a low-resolution placeholder and the full image.

```html
<div class="progressive-image">
  <img
    class="placeholder"
    src="images/mountain-small.jpg"
    alt=""
    aria-hidden="true"
  />

  <img
    class="full-image"
    src="images/mountain-large.jpg"
    alt="Snow-covered mountains at sunset"
    width="1200"
    height="800"
  />
</div>
```

The first image should be extremely small.

For example:

```text
mountain-small.jpg → 20–50 px wide
mountain-large.jpg → 1200 px wide
```

The tiny version downloads almost immediately.

The explicit `width` and `height` attributes are important because they reserve space before the image finishes loading and help prevent layout shifts.

* * *

## Step 2: Layer the Images With CSS

Now place both images in the same area.

```css
.progressive-image {
  position: relative;
  overflow: hidden;
  aspect-ratio: 3 / 2;
  background: #eee;
}

.progressive-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.placeholder {
  position: absolute;
  inset: 0;
  filter: blur(18px);
  transform: scale(1.08);
}

.full-image {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 350ms ease;
}
```

The placeholder is intentionally blurred.

The slight scaling:

```css
transform: scale(1.08);
```

helps hide blurry edges created by the CSS filter.

* * *

## Step 3: Reveal the Full Image When It Loads

Add a class after the high-resolution image is ready.

```javascript
document
  .querySelectorAll(".progressive-image")
  .forEach((wrapper) => {
    const image = wrapper.querySelector(".full-image");

    image.addEventListener("load", () => {
      image.classList.add("loaded");
    });
  });
```

Then add the following CSS:

```css
.full-image.loaded {
  opacity: 1;
}
```

Now the loading sequence becomes:

```text
Blurred Preview
      ↓
Large Image Loads
      ↓
opacity: 0 → 1
      ↓
Sharp Image
```

* * *

## Step 4: Wait for Image Decoding

The network request finishing does not always mean the browser has finished decoding the image.

For smoother replacement, use:

```javascript
image.decode()
```

The `decode()` method returns a promise that resolves after the image has been decoded and is ready to render.

Update the JavaScript:

```javascript
document
  .querySelectorAll(".progressive-image")
  .forEach(async (wrapper) => {
    const image = wrapper.querySelector(".full-image");

    try {
      if (!image.complete) {
        await new Promise((resolve, reject) => {
          image.addEventListener(
            "load",
            resolve,
            { once: true }
          );

          image.addEventListener(
            "error",
            reject,
            { once: true }
          );
        });
      }

      await image.decode();

      image.classList.add("loaded");
    } catch {
      wrapper.classList.add("image-error");
    }
  });
```

This avoids fading in an image before it is actually ready to paint.

* * *

## Step 5: Lazy Load Images Below the Fold

For images that are not visible immediately, let the browser delay the full-resolution request.

Add:

```html
<img
  class="full-image"
  src="images/mountain-large.jpg"
  loading="lazy"
  decoding="async"
  alt="Snow-covered mountains at sunset"
  width="1200"
  height="800"
/>
```

Native `loading="lazy"` tells the browser that off-screen images can be deferred until they approach the viewport.

Use it primarily for images **below the fold**.

* * *

## Do Not Lazy Load Your Hero Image

If an image is visible immediately and may become your Largest Contentful Paint element, lazy loading can delay it unnecessarily.

For a hero image, use:

```html
<img
  src="images/hero.jpg"
  alt="Product dashboard"
  width="1600"
  height="900"
  fetchpriority="high"
/>
```

`fetchpriority="high"` tells the browser that the image is relatively important. This hint should be used sparingly because assigning high priority to too many resources can reduce its effectiveness.

A simple rule is:

```text
Hero / LCP Image
→ eager + fetchpriority="high"

Below-the-fold Images
→ loading="lazy"
```

* * *

## Step 6: Add Responsive Images

Progressive loading should not mean sending a 2000-pixel image to every device.

Use `srcset`:

```html
<img
  class="full-image"
  src="images/mountain-1200.jpg"
  srcset="
    images/mountain-480.jpg 480w,
    images/mountain-800.jpg 800w,
    images/mountain-1200.jpg 1200w
  "
  sizes="
    (max-width: 600px) 100vw,
    800px
  "
  loading="lazy"
  decoding="async"
  width="1200"
  height="800"
  alt="Snow-covered mountains at sunset"
/>
```

The browser can now choose the most appropriate image based on the display size and device pixel ratio.

Using `srcset` and `sizes` helps prevent browsers from downloading unnecessarily large images on smaller devices.

Responsive image handling is also an important part of [performance-focused web development](https://sdlccorp.com/website-development-company/), especially when teams are optimizing Core Web Vitals, mobile layouts, and page-load speed.

* * *

## Step 7: Remove the Placeholder After Loading

Once the full image is visible, the placeholder is no longer necessary.

Update the code:

```javascript
await image.decode();

image.classList.add("loaded");

image.addEventListener(
  "transitionend",
  () => {
    const placeholder =
      wrapper.querySelector(".placeholder");

    placeholder?.remove();
  },
  { once: true }
);
```

Now the browser can discard the unnecessary placeholder element after the transition finishes.

* * *

## Complete Progressive Image Loading Example

### HTML

```html
<div class="progressive-image">
  <img
    class="placeholder"
    src="images/mountain-small.jpg"
    alt=""
    aria-hidden="true"
  />

  <img
    class="full-image"
    src="images/mountain-1200.jpg"
    srcset="
      images/mountain-480.jpg 480w,
      images/mountain-800.jpg 800w,
      images/mountain-1200.jpg 1200w
    "
    sizes="
      (max-width: 600px) 100vw,
      800px
    "
    loading="lazy"
    decoding="async"
    width="1200"
    height="800"
    alt="Snow-covered mountains at sunset"
  />
</div>
```

### CSS

```css
.progressive-image {
  position: relative;
  overflow: hidden;
  aspect-ratio: 3 / 2;
  background: #eee;
}

.progressive-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.placeholder {
  position: absolute;
  inset: 0;
  filter: blur(18px);
  transform: scale(1.08);
}

.full-image {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 350ms ease;
}

.full-image.loaded {
  opacity: 1;
}
```

### JavaScript

```javascript
document
  .querySelectorAll(".progressive-image")
  .forEach(async (wrapper) => {
    const image = wrapper.querySelector(".full-image");

    try {
      if (!image.complete) {
        await new Promise((resolve, reject) => {
          image.addEventListener(
            "load",
            resolve,
            { once: true }
          );

          image.addEventListener(
            "error",
            reject,
            { once: true }
          );
        });
      }

      await image.decode();

      image.classList.add("loaded");

      image.addEventListener(
        "transitionend",
        () => {
          wrapper
            .querySelector(".placeholder")
            ?.remove();
        },
        { once: true }
      );
    } catch {
      wrapper.classList.add("image-error");
    }
  });
```

* * *

## What About JavaScript-Based Lazy Loading?

Older implementations often used `IntersectionObserver` to decide when to assign an image URL.

That approach still has valid use cases when you need custom loading behavior, but native:

```html
loading="lazy"
```

is usually simpler for standard image lazy loading.

Intersection Observer remains useful when you need more control over exactly when elements enter or leave the viewport.

Avoid adding custom JavaScript when the browser already provides the behavior you need.

* * *

## Progressive Loading vs Progressive JPEG

These two concepts are related but different.

**Progressive JPEG** encodes one image so it appears in multiple visual passes while downloading.

The technique used in this tutorial relies on:

```text
Tiny Separate Image
        +
Full Resolution Image
        +
CSS Transition
```

This gives developers more control over:

*   Placeholders
    
*   Responsive images
    
*   Lazy loading
    
*   Image decoding
    
*   Transition behavior
    

* * *

## Common Progressive Image Loading Mistakes

Avoid these common issues:

*   Lazy loading the main hero or LCP image
    
*   Serving the same huge image to every device
    
*   Omitting `width` and `height`
    
*   Using a placeholder that is still hundreds of kilobytes
    
*   Applying excessive blur effects
    
*   Loading the full image twice
    
*   Waiting only for `load` when smooth decoding matters
    
*   Forgetting meaningful `alt` text
    
*   Writing complex JavaScript when native lazy loading is enough
    

The goal is not to make the implementation complicated.

The goal is to improve **perceived loading speed without harming actual performance**.

A strong [frontend user experience strategy](https://sdlccorp.com/ui-ux-design-company/) [](https://sdlccorp.com/ui-ux-design-company/)should balance visual quality with performance so placeholders, responsive layouts, and loading states improve the experience instead of distracting users.

* * *

## Recommended Image Loading Strategy

A practical setup looks like this:

### Above-the-Fold Images

```text
Above the Fold
      ↓
Responsive Image
      ↓
fetchpriority="high"
      ↓
Display Immediately
```

### Below-the-Fold Images

```text
Below the Fold
      ↓
Tiny Placeholder
      ↓
loading="lazy"
      ↓
Responsive Full Image
      ↓
decode()
      ↓
Fade In
```

This approach prioritizes important visual content while delaying resources that users do not immediately need.

* * *

## Final Thoughts

Good **progressive image loading** combines several small techniques rather than relying on a single trick.

Use a lightweight placeholder to give users immediate visual feedback. Reserve image dimensions to prevent layout shifts. Let native lazy loading defer non-critical images, use responsive sources to avoid unnecessary downloads, and wait for decoding before revealing the final image.

The simplest rule is:

```text
Load important images early.
Defer unimportant images.
Never download more image data than necessary.
```

Following these principles gives users a smoother image-loading experience without introducing a heavy image-loading library.
