How to Fix: Image onLoad/onLoadingComplete insufficient for loading component
Next.js Image Skeleton Bug: Why onLoad and onLoadingComplete Fire Too Late or Not Reliably for Loading UI
If your Skeleton loader never disappears, disappears too early, or behaves inconsistently with the Next.js Image component, the problem is usually not your placeholder logic. The real issue is how next/image handles caching, hydration, lazy loading, and placeholder rendering across server and client boundaries.
Table of Contents
Understanding the Root Cause
The bug appears when developers use onLoad or onLoadingComplete to hide a loading component such as a Skeleton. In theory, that sounds correct: render the placeholder first, then remove it once the image finishes loading. In practice, Next.js Image adds several layers that make this unreliable if you only depend on those callbacks.
Here is what is happening technically:
- Browser cache: if the image is already cached, the browser may complete loading before your React effect or state is ready to observe it in the expected way.
- Hydration timing: the server sends markup first, then React hydrates it on the client. During that window, the image may already be available, while your loading state still says
false. - Lazy loading:
next/imagedoes not always fetch immediately. If the image is offscreen or deferred, your Skeleton can remain visible longer than expected. - Placeholder behavior: blurred placeholders and actual image decoding are separate phases. The image element may exist before the final bitmap is fully ready for display.
- Event differences:
onLoadis a native image event, whileonLoadingCompleteis a Next.js helper that has changed across versions and should not be treated as the only source of truth for rendering UI state.
The result is a classic UI race condition: your loading state is controlled only by an event callback, but the image lifecycle can complete outside the exact timing that callback-based logic assumes.
The most reliable fix is to combine event-based updates with an immediate check against the underlying image element’s complete state after mount.
Step-by-Step Solution
The goal is simple: show the Skeleton until the image is genuinely ready, but also handle images that are already loaded from cache.
1. Build a client component with explicit loading state
Start with a wrapper around Image that owns its own isLoaded state.
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
type Props = {
src: string;
alt: string;
width: number;
height: number;
};
export default function ImageWithSkeleton({ src, alt, width, height }: Props) {
const [isLoaded, setIsLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const img = imgRef.current;
if (img && img.complete) {
setIsLoaded(true);
}
}, [src]);
return (
<div style="position: relative; display: block;">
{!isLoaded && (
<div
aria-hidden="true"
style="position: absolute; inset: 0; background: #e5e7eb; border-radius: 8px;"
/>
)}
<Image
ref={imgRef}
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setIsLoaded(true)}
style={{ opacity: isLoaded ? 1 : 0, transition: "opacity 0.2s ease" }}
/>
</div>
);
}
This solves the biggest gap: cached images. If the image has already loaded before your callback logic matters, the img.complete check flips state immediately.
2. Reset state when the image source changes
If the src changes dynamically, you must reset the loading state first. Otherwise, the previous image can leave the next image stuck in a loaded state.
useEffect(() => {
setIsLoaded(false);
const img = imgRef.current;
if (img && img.complete) {
setIsLoaded(true);
}
}, [src]);
This ensures each new image starts fresh.
3. Prefer onLoad for state changes
In current implementations, onLoad is usually the most direct event to watch. If you are using older code that depends on onLoadingComplete, switch to onLoad unless you have a version-specific reason not to.
<Image
ref={imgRef}
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setIsLoaded(true)}
/>
4. Make the Skeleton overlay the image area correctly
A common mistake is rendering the Skeleton above or below the image without a stable container. The loading UI should live inside a relative parent so layout does not jump.
<div style="position: relative;">
{!isLoaded && (
<div
aria-hidden="true"
style="position: absolute; inset: 0; background: #e5e7eb;"
/>
)}
<Image ... />
</div>
This prevents layout shift and keeps the placeholder aligned with the image bounds.
5. Use opacity instead of conditional image removal
Do not mount the image only after loading finishes. The image must exist so the browser can fetch and decode it. Instead, keep the image mounted and fade it in.
<Image
ref={imgRef}
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setIsLoaded(true)}
style={{ opacity: isLoaded ? 1 : 0 }}
/>
If you conditionally render the image only after state changes, the loading event can never happen because the image never mounted.
6. Full recommended example
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
type ImageWithSkeletonProps = {
src: string;
alt: string;
width: number;
height: number;
className?: string;
};
export function ImageWithSkeleton({
src,
alt,
width,
height,
className,
}: ImageWithSkeletonProps) {
const [isLoaded, setIsLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
setIsLoaded(false);
const img = imgRef.current;
if (img?.complete) {
setIsLoaded(true);
}
}, [src]);
return (
<div style={{ position: "relative" }} className={className}>
{!isLoaded && (
<div
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
backgroundColor: "#e5e7eb",
borderRadius: "8px",
animation: "pulse 1.5s ease-in-out infinite",
}}
/>
)}
<Image
ref={imgRef}
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setIsLoaded(true)}
style={{
opacity: isLoaded ? 1 : 0,
transition: "opacity 200ms ease",
}}
/>
</div>
);
}
This pattern is the safest fix for the issue described in the reproduction. It handles initial load, cached images, and src updates without relying on a single callback path.
For the original reproduction context, compare your implementation against the reported issue and test with both a hard refresh and repeated navigation. If you need to inspect the upstream discussion, review the linked reproduction example.
Common Edge Cases
Cached image loads instantly
This is the most common reason the Skeleton logic breaks. The browser may already have the image available, so relying only on onLoad can produce inconsistent UI. The img.complete check fixes that.
Image source changes during navigation
If a gallery, carousel, or route transition swaps the src, the prior loaded state must be cleared. Always reset state when src changes.
Using fill instead of width and height
When using fill, the parent container needs explicit dimensions. Otherwise, your Skeleton may render with no visible area or collapse unexpectedly.
<div style={{ position: "relative", aspectRatio: "16 / 9" }}>
<Image src={src} alt={alt} fill onLoad={() => setIsLoaded(true)} />
</div>
Blur placeholder conflicts with custom Skeleton
If you use placeholder="blur" and a separate Skeleton overlay, both loading states can compete visually. Decide whether you want the native blur placeholder, a custom Skeleton, or a coordinated combination of both.
Server Components vs Client Components
If you need local loading state, the wrapper must be a Client Component. Without "use client", hooks like useState and useEffect will not work.
Broken or failed image requests
If the image fails to load, your Skeleton may remain visible forever unless you also handle onError.
<Image
ref={imgRef}
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setIsLoaded(true)}
onError={() => setIsLoaded(true)}
/>
In production, you may want to replace the Skeleton with a fallback image or error state instead of simply hiding it.
FAQ
Why is onLoadingComplete not enough for a Skeleton loader?
Because image readiness is affected by cache, hydration, and rendering timing. A callback alone does not cover every path. Checking img.complete after mount makes the component resilient.
Should I use onLoad or onLoadingComplete with next/image?
For most loading UI cases, use onLoad plus a ref-based complete check. That combination is simpler and more reliable for toggling placeholder state.
Why does my Skeleton never disappear when the image is visible?
This usually means one of three things: the image loaded from cache before your state updated, the component did not reset state when src changed, or your Skeleton is layered incorrectly and remains above the image due to CSS positioning.
The practical fix is to stop treating Next.js Image events as the only signal. Use a wrapper component that tracks loading state, checks img.complete, resets on src changes, and keeps the image mounted while fading it in. That gives you a stable, production-safe Skeleton behavior.