Exploring Progressive Image Loading Techniques

A deep dive into LQIP, BlurHash, ThumbHash, and other progressive image loading strategies for the web.

In modern web development, developers use loading animations, skeleton screens, or blurred/low-res placeholders to make apps feel faster than they actually load.

Traditional Approach 🚬

Say you have this HTML:

<img src="https://assets.vluv.space/4k-wallpaper.webp" alt="404"/>

Simple as it is, on slow networks the user stares at nothing for a long time. The gantt chart below illustrates the timeline:

Even with 5G, you might still face:

  • Low-bandwidth blog servers — CDN throttling, origin throttling, etc.
  • Large image files 👍
  • Personal preference — some of us just love tinkering

In short, optimization still matters.

LQIP 🌄

LQIP (Low-Quality Image Placeholder) was popularized by Facebook’s engineering team in 2015[1], originally to address slow cover image loading on mobile.

The core idea is straightforward:

  • Server-side: heavily compress the image while preserving its aspect ratio, producing a tiny low-resolution image.
  • Client-side: stretch this low-res image to the target size and overlay a Gaussian Blur filter for display.

In Facebook’s technical article, they pushed compression further by pre-bundling a standard JPEG header on the client, so the server only needs to send a version number, dimensions, and roughly 200 bytes of image data.

On the timeline, LQIP’s value shows during the gap before the original image finishes loading: instead of a solid-color placeholder, the user sees a blurry preview that closely matches the final content. This creates a smoother visual experience. The cover images on this blog currently use this approach.

The two examples[2] below show the experience gain from LQIP:

ThumbHash & BlurHash 🫨

Beyond LQIP, ThumbHash and BlurHash tackle the same UX problem: show a visually similar placeholder as early as possible before the original image loads. But their implementation path differs from LQIP:

How They Work

BlurHash extracts an image and produces a short string of only 20–30 characters as a placeholder representation. You run this on the server and store the string alongside the image. When sending data to the client, you include both the image URL and the BlurHash string. The client decodes the string into a blurred image and displays it while the real image loads. The string is short enough to embed in any data format — for example, as a field in a JSON object.[3]

ThumbHash[4] can be seen as an evolution of BlurHash:

  • Encodes more detail in the same space
  • Also encodes the aspect ratio
  • Gives more accurate colors
  • Supports images with alpha

Limitation & Workaround

Although ThumbHash and BlurHash are conceptually elegant, in a web context there is an unavoidable reality: a hard dependency on JavaScript.

Browsers cannot directly parse a hash string and render an image — they need a JavaScript decoding library. JS typically executes after HTML parsing completes, so the placeholder itself may suffer a perceptible delay.

A workaround is to decode the hash back into a Base 64 image on the server and embed it directly into the HTML:

Summary

This blog uses LQIP for cover images and the Base64-in-HTML workaround for in-article images. Across roughly 400 images on the site, PNG-format data URLs total about 1 MB, averaging 2.5 KB per image. Compared to the 20–30 byte ThumbHash/BlurHash strings, that’s over 100 times larger (but it drops the JavaScript dependency 😎).

If you transform the image to a blurhash and back to an image, then send the image to the customer, what you’ve done is treat BlurHash as a preprocessing filter that loses the majority of its benefits.

Justin Greer

However, after seeing the size comparison on the Blur Up Thumbs page, I realized I had taken a slight detour. But the project already had path dependency, and I couldn’t be bothered to tear it all down — so as a compromise, I converted those data URLs to WebP. The total size for 400 images dropped to roughly 110 KB, averaging 275 bytes per image.

Not the optimal solution, but: 🧄🐦🧄🐦 (making Wuhan sounds)

蒜鸟
蒜鸟

References

<style>.cover-image {  position: relative;  display: block;  overflow: hidden;  height: 380px;  img {    display: block; width: 100%; height: 100%; object-fit: cover;  }  .cover-lqip {    position: absolute; top: 0; left: 0; z-index: 1; filter: blur(10px);  }  .cover-origin {    position: relative; z-index: 2;  }}</style><a href="xxx" class="cover-image">  <img class="cover-lqip" src={lqip_src} alt="placeholder" />  <img class="cover-origin" src={cover} srcset="xxx" alt="xxx" decoding="async" loading="lazy"/></a>
<div class="pic"    style="aspect-ratio: 3638 / 2032; background-image: url(data:image/webp;base64,U...==); background-repeat: no-repeat; background-size: cover; width: 100%; max-width: 3638px; position: relative; overflow: hidden;">    <img onload="this.style.opacity=1; setTimeout(() => { this.parentElement.style.backgroundImage='none'; }, 600);"        src="https://assets.vluv.space/xxx.webp"        srcset="xxx"        style="object-fit: cover; opacity: 1; width: 100%; height: 100%; transition: opacity 0.6s ease-in-out;"        alt="xxx" decoding="async" loading="lazy"></div>

  1. See The technology behind preview photos - Engineering at Meta ↩︎

  2. Video source: A clear look at blurry image placeholders on the web | Mux by Wesley Luyten ↩︎

  3. Principle excerpted from BlurHash ↩︎

  4. ThumbHash: A very compact representation of an image placeholder ↩︎

CompactRelaxed
Normal1.70