How We Load Images Without the Ugly Flash
If you read articles online, you've seen it: blank image spaces or layout jumps while images load. We avoided that — here’s how and why it works.

Martin Binder
bndrmrtn@gmail.com
The Problem With Naive Image Loading
Drop an <img> tag into a page and you get the browser’s default behaviour: nothing, then suddenly everything. For slow connections or large hero images, that gap is long enough to feel broken. Even on fast connections, there is a flash, and once you notice it, you cannot unsee it.
The obvious fix, showing a placeholder, only moves the problem. A grey box is better than nothing, but it is still a hard cut when the real image arrives.
We wanted something closer to what you see in high-end apps: the image fades in from a blurry preview, so the transition is smooth even if you are on a coffee shop connection.
The Technique: Blur-Up
The idea is simple in principle:
- Generate a tiny, blurred version of each image on the server, a few hundred bytes at most.
- Show that blur immediately while the full image loads in the background.
- Once the full image is ready, fade it in over the blur.
The result is that the user always sees something that resembles the final image from the very first paint. The transition feels natural rather than mechanical.
This pattern has been standard practice in performance-conscious apps for years. Gatsby called their implementation “blur-up”. Prismic, Contentful, and most modern image CDNs offer it natively. We built ours in-house so we have full control.
Our Implementation
Generating the Blur on the Server
We have a small API endpoint, /api/premage/[filename], that takes any image filename and returns a highly compressed, tiny version of it. This runs at request time and the result is cached, so each image only gets processed once.
On the client, the component fetches this thumbnail, base64-encodes it, and uses it as the src of a regular <img> tag. Because it is base64-inlined, there is no second network request. The blur is available the instant the component renders.
const buffer = await $fetch(`/api/premage/${filename}`, {
responseType: 'arrayBuffer',
});
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
const blurDataUrl = `data:image/jpeg;base64,${base64}`;
The Crossfade
Two images are stacked absolutely on top of each other inside a container. The blur sits underneath and the full resolution image sits on top. Both have a CSS opacity transition.
- While loading: blur is
opacity-100, full image isopacity-0. - Once loaded: blur fades to
opacity-0, full image fades toopacity-100.
The blur also gets a subtle scale(1.1) and blur(4px) applied so its edges do not show hard pixels at the container boundary. It is a small touch but it makes the preview look intentional rather than broken.
The Cached Image Gotcha
Here is the part that trips most people up.
When a browser has already loaded an image, it serves it from cache. The load event fires before Vue finishes mounting the component and attaching event listeners, so the listener never runs, loaded stays false, and the blur never clears. The user sees a permanently blurry image even though the full version is sitting right there in cache.
The fix is a one-liner in onMounted:
onMounted(() => {
const el = imgRef.value?.$el ?? imgRef.value;
if (el?.complete) {
loaded.value = true;
}
});
img.complete is true the moment the browser has the image available, regardless of whether the load event already fired. Checking it after mount catches every cached case cleanly.
Why Not Just Use a Library?
Libraries like nuxt-image’s built-in placeholder, next/image, or unpic handle some of this out of the box. We use NuxtImg for format optimisation and responsive sizing, but the blur-up logic we own ourselves, for a few reasons:
Control over the blur source. We wanted the preview generated server-side from the original file, not a CSS filter applied to a resized version. The quality difference is noticeable at larger sizes.
No layout shift. We fix the container dimensions in CSS rather than relying on the image’s intrinsic size. This means we know exactly what space will be occupied before any network request completes.
Cache correctness. The img.complete check described above is not handled by most off-the-shelf components. It is a small fix, but without it, returning visitors see a regression compared to first-time visitors.
The Result
On a fast connection, the transition is almost imperceptible, which is exactly the point. The image is just there. On a slow connection, readers see a recognisable, content-aware placeholder rather than a blank box, which makes the page feel much more alive while it loads.
It is a small amount of code for a meaningful improvement in perceived performance. The kind of detail that nobody notices when it is working correctly, and everybody notices when it is not.