
Broken images quietly hurt user experience. They slow pages down, break layouts, and signal unreliable data. When we’re working with dynamic content, user uploads, or third-party APIs, validating an image source before rendering becomes essential.
In this guide, we’ll walk through how to check if an image’s src is valid in JavaScript, starting with simple browser-native techniques and moving into more advanced validation strategies that scale. Along the way, we’ll focus on practical, production-ready approaches to catch failures early and ship more resilient interfaces.
In this article:
- How to Check if an Image src Is Valid in JavaScript
- What “Valid Image Source” Means: URL Reachability, Status Codes, MIME Types, and Loadability
- Method 1: Using Image() with onload/onerror for a Quick Validation
- Method 2: Using fetch() to Inspect Status Codes and Content-Type
- Method 3: Preloading & Fallback Techniques for <img> Elements
- Edge Cases: CORS, Data URLs, Blob URLs, and Caching Surprises
- Validating Many Images at Once: Performance Tips
- Common Issues and How to Debug Invalid Image Sources
How to Check if an Image src Is Valid in JavaScript
If you need a fast, reliable way to check whether an image src is valid, the simplest approach is to let the browser do the work for us. We can create a new Image object, assign the src, and listen for the onload and onerror events.
Here’s the most common pattern:
function isValidImage(src) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = src;
});
}
After we’ve made the function to check, using it is simple:
isValidImage('https://example.com/image.jpg')
.then(isValid => {
if (isValid) {
console.log('Image source is valid');
} else {
console.log('Image source is invalid');
}
});
This works because the browser only fires onload when the resource:
- Is reachable
- Returns a valid image response
- Can be decoded as an image format that the browser understands
If the URL is broken, returns a 404, serves non-image content, or fails to decode, onerror fires instead.
This approach works well because no extra libraries are required, it matches real rendering behavior, and it works with most image formats and CDNs. Usually, this is all you’d need. However, it doesn’t expose HTTP status codes or headers, and it can behave differently with CORS-restricted resources.
What “Valid Image Source” Means: URL Reachability, Status Codes, MIME Types, and Loadability
Before we pick a validation strategy, it’s important to clarify what you actually mean by a “valid” image source. In practice, image validity is a combination of conditions that determine whether an image can be safely rendered in the browser.
URL Reachability
At the most basic level, the image URL must be reachable. That means:
- The domain resolves correctly
- The request doesn’t time out
- The server responds at all.
A URL that fails DNS resolution or never responds will always result in a broken image, no matter how correct the rest of your code is.
HTTP Status Codes
Even if a URL is reachable, the HTTP response still matters. Common scenarios include:
- 200 OK: The request succeeded and returned a response body
- 404 Not Found: The image doesn’t exist
- 403 Forbidden: The resource exists but isn’t accessible
- 500+: Server-side errors
From a browser’s perspective, any non-success response typically results in an image load failure. However, JavaScript-based checks may or may not expose these status codes, depending on the method you use.
MIME Type (Content-Type)
A URL can return a 200 response and still not be a valid image. For example, an endpoint might respond with Content-Type: text/html instead of Content-Type: image/jpeg.
In this case, the browser won’t be able to decode the resource as an image, and rendering will fail. When using fetch(), inspecting the Content-Type header is one of the most reliable ways to catch this issue early.
Image Loadability and Decoding
Finally, the browser must be able to decode the image data. Even with:
- A reachable URL
- A 200 status
- A valid
image/*MIME type
The image can still fail to load if the file is corrupted or encoded incorrectly. This is why checks that rely on actual image loading (onload / onerror) are often the most accurate; they reflect real rendering behavior.
There Is No Single “Perfect” Check
Different validation methods answer different questions:
- Image loading checks tell us if the browser can render the image
- Network checks tell us why it failed
- Header inspection helps prevent obvious mismatches early
In real-world applications, we often combine these techniques based on performance needs, security constraints, and how much diagnostic detail we need.
Method 1: Using Image() with onload and onerror for Quick Validation
If our goal is “Will this actually render as an image in the browser?”, Image() is the quickest, most realistic validation. You create an in-memory image, point it at a URL, and let the browser tell us what happened.
Basic Image Validation with Promises
function checkImageSrc(src) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ ok: true, src });
img.onerror = () => resolve({ ok: false, src });
img.src = src;
});
}
This answers the most important question: Did the browser successfully load and decode the image?
Adding a Timeout to Prevent Stalling
Some failures don’t trigger quickly (such as slow networks or stalled hosts). A timeout keeps your UI from waiting indefinitely:
function checkImageSrc(src, { timeoutMs = 7000 } = {}) {
return new Promise((resolve) => {
const img = new Image();
let done = false;
const finish = (ok, reason) => {
if (done) return;
done = true;
// Reduce the chance of leaks in long-running apps
img.onload = null;
img.onerror = null;
resolve({ ok, src, reason });
};
const timer = setTimeout(() => finish(false, "timeout"), timeoutMs);
img.onload = () => {
clearTimeout(timer);
finish(true);
};
img.onerror = () => {
clearTimeout(timer);
finish(false, "error");
};
img.src = src;
});
}
Now we can use it like this:
const result = await checkImageSrc("https://example.com/photo.jpg", { timeoutMs: 5000 });
if (result.ok) {
console.log("Valid image:", result.src);
} else {
console.warn("Invalid image:", result.reason, result.src);
}
Validate Loadable and Non-Zero Dimensions
Occasionally, we’ll want to ensure the image has real dimensions (something that’s useful for layout decisions):
function checkImageSrcWithSize(src, { timeoutMs = 7000 } = {}) {
return new Promise((resolve) => {
const img = new Image();
let done = false;
const finish = (ok, reason) => {
if (done) return;
done = true;
img.onload = null;
img.onerror = null;
resolve({
ok,
src,
reason,
width: ok ? img.naturalWidth : 0,
height: ok ? img.naturalHeight : 0,
});
};
const timer = setTimeout(() => finish(false, "timeout"), timeoutMs);
img.onload = () => {
clearTimeout(timer);
if (img.naturalWidth > 0 && img.naturalHeight > 0) finish(true);
else finish(false, "zero-dimensions");
};
img.onerror = () => {
clearTimeout(timer);
finish(false, "error");
};
img.src = src;
});
}
When This Method Is a Great Fit (And When It Isn’t)
Use Image() when:
- You care about real browser behavior (for both load and decode)
- You don’t need status codes or headers
- You want a simple client-side “is it broken?” check
Don’t rely on it when:
- You need a specific HTTP status code, such as for debugging
- You need to check
Content-Typebefore loading - CORS restrictions change what you can inspect
This approach is the fastest way to catch broken src values, and it matches what users will experience in the UI. Next, we’ll switch gears and use fetch() to inspect status codes and MIME types before we attempt to render anything.
Method 2: Using fetch() to Inspect Status Codes and Content-Type
Image() tells us whether the browser can load and decode the asset. But sometimes we need more detail, such as whether the server returned a 404, whether we got redirected, or whether the URL is serving HTML instead of an actual image. That’s where fetch() helps.
What fetch() Can and Can’t Validate
With fetch(), we can typically verify:
- Reachability: Did we get a response?
- HTTP Status:
200,404,403, or others. - Content Type: Is it
image/*? - Redirect Behavior: Did we land somewhere unexpected?
However, fetch() doesn’t guarantee the browser can decode the bytes as a valid image, as corrupted files can still pass headers.. For true “renderability,” we’ll often pair this with an Image() check.
Option A: Fast header check with HEAD
A HEAD request asks the server for headers only, no response body, so it’s often cheaper.
async function checkImageByHeaders(url, { timeoutMs = 7000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
cache: "no-store",
});
const contentType = res.headers.get("content-type") || "";
const isImage = contentType.toLowerCase().startsWith("image/");
return {
ok: res.ok && isImage,
status: res.status,
redirected: res.redirected,
url: res.url, // final URL after redirects
contentType,
reason: !res.ok ? "bad-status" : !isImage ? "not-image-content-type" : "ok",
};
} catch (err) {
return { ok: false, status: 0, redirected: false, url, contentType: "", reason: "network-or-timeout" };
} finally {
clearTimeout(timer);
}
}
However, this can still fail, even when in production, from issues like:
- Some servers or CDNs not supporting
HEADcorrectly. - Some endpoints return different headers for
HEADvsGET. - Cross-origin requests may block header access if CORS isn’t configured.
Option B: Use GET when HEAD is Blocked or Unreliable
If HEAD fails, a lightweight GET can be more consistent. We still don’t need to download the whole file if we only care about headers, but the browser may still download a chunk of the body depending on the environment.
async function checkImageWithFetch(url, { timeoutMs = 7000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
signal: controller.signal,
redirect: "follow",
cache: "no-store",
});
const contentType = res.headers.get("content-type") || "";
const isImage = contentType.toLowerCase().startsWith("image/");
return {
ok: res.ok && isImage,
status: res.status,
redirected: res.redirected,
url: res.url,
contentType,
reason: !res.ok ? "bad-status" : !isImage ? "not-image-content-type" : "ok",
};
} catch (err) {
return { ok: false, status: 0, redirected: false, url, contentType: "", reason: "network-or-timeout" };
} finally {
clearTimeout(timer);
}
}
Best Practice: Combine fetch()and Image()
If we want a reliable “valid image” check, a solid approach is:
fetch()to confirm the status andContent-Typeof the image.
Image() to confirm the browser can both load and decode it.
async function validateImageSrc(url) {
const headerCheck = await checkImageByHeaders(url).catch(() => null);
const effectiveCheck = headerCheck?.ok ? headerCheck : await checkImageWithFetch(url);
if (!effectiveCheck.ok) return { ok: false, stage: "headers", ...effectiveCheck };
const loadCheck = await (new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ ok: true });
img.onerror = () => resolve({ ok: false });
img.src = url;
}));
return loadCheck.ok
? { ok: true, stage: "decode", ...effectiveCheck }
: { ok: false, stage: "decode", ...effectiveCheck, reason: "failed-to-decode" };
}
This gives us:
- Actionable error signals (such as status code, MIME mismatch, or redirects).
- Real-world confidence that the image will render for the user.
Method 3: Preloading & Fallback Techniques for <img> Elements
Sometimes we don’t want to “validate then render.” We just want images to render reliably, with a clean fallback when something goes wrong. In UI work, that’s often the better goal: avoid broken-image icons, preserve layout, and keep the page feeling intentional.
Pattern 1: Use onerror to Swap to a Fallback Image
This is the classic approach: if the image fails, swap the src to a known-good placeholder.
<img src="https://example.com/user-upload.jpg" alt="User upload" onerror="this.onerror=null; this.src='/images/fallback-avatar.png';" />
Two important details:
this.onerror = nullprevents an infinite loop if the fallback also fails.- Always include image alt text—fallbacks shouldn’t block accessibility.
If you prefer keeping logic out of HTML:
function applyImgFallback(imgEl, fallbackSrc) {
imgEl.addEventListener("error", () => {
// Prevent loops
imgEl.onerror = null;
imgEl.src = fallbackSrc;
}, { once: true });
}
Pattern 2: Preload First, Then Set the <img> src Only if It Loads
If you want to avoid ever showing a broken icon, preload with Image() and only “commit” the src after success.
async function setImageSafely(imgEl, src, fallbackSrc) {
const ok = await new Promise((resolve) => {
const probe = new Image();
probe.onload = () => resolve(true);
probe.onerror = () => resolve(false);
probe.src = src;
});
imgEl.src = ok ? src : fallbackSrc;
}
This is a great fit for:
- Profile photos or avatars
- Product thumbnails
- CMS-driven image grids where some URLs are stale
Pattern 3: Keep Layout Stable With Width, Height, Or Aspect Ratio
Even if we handle errors, images that load late can still cause layout shifts. The fix is to reserve space:
<img src="https://example.com/photo.jpg" alt="Cover photo" width="800" height="450" />
Or, for responsive cards:
.card-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
}
This prevents “jumping” content, which helps perceived performance and Core Web Vitals.
Pattern 4: Loading states and progressive reveal
A small UX touch: show a skeleton (or a blurred placeholder) and reveal the image once it’s loaded.
function revealOnLoad(imgEl, { loadedClass = "is-loaded", errorClass = "is-error" } = {}) {
imgEl.classList.remove(loadedClass, errorClass);
imgEl.addEventListener("load", () => imgEl.classList.add(loadedClass), { once: true });
imgEl.addEventListener("error", () => imgEl.classList.add(errorClass), { once: true });
}
And the accompanying CSS:
img.progressive {
opacity: 0;
transition: opacity 150ms ease;
background: #f2f2f2;
}
img.progressive.is-loaded {
opacity: 1;
}
img.progressive.is-error {
opacity: 1;
/* Optionally show a "missing" background or swap the src */
}
Pattern 5: A Reusable Component Wrapper
Even if you’re not using a framework like React or Vue, thinking in “component patterns” helps. The goal: a single place to standardize fallback behavior.
For plain JavaScript, you can use this:
function mountSafeImage(imgEl, { src, fallbackSrc }) {
// Start with fallback or blank to avoid broken UI flashes
imgEl.src = src;
imgEl.addEventListener("error", () => {
imgEl.onerror = null;
imgEl.src = fallbackSrc;
}, { once: true });
}
When to Use These Methods
- Use an
onerrorfallback when you want the simplest “never broken” behavior. - Use preload-then-commit when you want to avoid the broken icon entirely.
- Use layout reservation when you care about stable UI and preventing jumps.
- Use loading states when you want the UI to feel polished, especially in grids.
Edge Cases: CORS, Data URLs, Blob URLs, and Caching Surprises
CORS: Why fetch() Might Fail While <img> Still Loads
An <img> URL can often load cross-origin without any special CORS headers. A `fetch()`, however, is subject to CORS and may fail (or return an “opaque” response) unless the server allows it.
What this means in practice:
- If you need a “can it render?” check,
Image()is usually more reliable thanfetch()for cross-origin URLs. - If you need headers like
Content-Type, you’ll only get them when the server responds with appropriate CORS headers (for example,Access-Control-Allow-Origin).
Tip: If you’re validating images hosted on your own infrastructure or CDN, configure CORS to allow your app origins so fetch() can inspect headers.
Data URLs: Valid Doesn’t Mean Desirable
Data URLs are structured like so:
const src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...";
Image() works well here because the browser decodes the bytes directly. On the other hand, fetch() isn’t the right tool for status codes: there isn’t a network request or an HTTP response. Data URLs can be huge, bloat HTML/JS bundles, and defeat caching. They’re useful for tiny placeholders, but not great for real assets.
Blob URLs: Only Valid in the Current Session and Origin Context
Blob URLs are generated by the browser:
const blobUrl = URL.createObjectURL(fileOrBlob);
Image() can load blob URLs as long as the blob is a valid image. But, blob URLs are not globally reachable. They only exist in the current browser context (and typically only while the page or session is alive).
A common issue with using blobs is trying to validate a blob URL from a different tab/session/app instance. Alternatively, forgetting to revoke the URL when done can leak memory:
URL.revokeObjectURL(blobUrl);
If you’re seeing “it worked once, then never again,” blob URL lifecycle is often the reason.
Caching Issues: Inconsistent Image Validation
Browsers cache aggressively, and different validation methods may hit different caches. Some common scenarios can include:
- A previously valid image becomes invalid, but cached bytes still load for some users.
- You fix an image at the origin, but clients still see failures due to stale caches.
- Your
fetch()and<img>load take different paths depending on cache headers and request mode.
To tackle some of these issues, try:
- During debugging, disable cache in DevTools and hard refresh.
- When you control the URL, use versioned URLs (like
_avatar_v3.jpg) rather than “same URL, new content.” - If you’re validating before display and need “freshness,” consider
cache: "no-store"withfetch(), but don’t do that for every image in production unless you really need it (it can hurt performance).
Validating Many Images at Once: Performance Tips
Checking a single image is easy. But checking dozens or hundreds can quickly turn into a performance problem if we’re not careful. Here are practical techniques for validating image sources at scale without overwhelming the browser or the network.
1. Limit Concurrent Checks
Creating too many Image() or fetch() requests at once can:
- Saturate the network
- Delay critical page resources
- Freeze lower-powered devices
A simple concurrency limiter keeps things predictable. Here’s a simple example:
async function runWithConcurrency(items, limit, worker) {
const results = [];
const queue = [...items];
const runners = Array.from({ length: limit }, async () => {
while (queue.length) {
const item = queue.shift();
results.push(await worker(item));
}
});
await Promise.all(runners);
return results;
}
And here it is in action:
const imageUrls = [...document.querySelectorAll("img")].map(img => img.src);
const results = await runWithConcurrency(
imageUrls,
4, // limit concurrent checks
(src) => checkImageSrc(src)
);
2. Skip Obvious No-Op Cases Early
Not every src needs validation.
Before doing any network or decode work, filter out:
- Empty or missing
srcvalues - Known-good placeholders
- Inline SVGs or icons you control
- Previously validated URLs
function shouldValidateSrc(src) {
if (!src) return false;
if (src.startsWith("data:image/svg+xml")) return false;
if (src.includes("/static/known-good/")) return false;
return true;
}
3. Cache Validation Results (Especially for Shared URLs)
In lists and grids, the same image URL often appears multiple times. Validate once, reuse everywhere.
const imageValidationCache = new Map();
async function cachedImageCheck(src) {
if (imageValidationCache.has(src)) {
return imageValidationCache.get(src);
}
const result = await checkImageSrc(src);
imageValidationCache.set(src, result);
return result;
}
This is one of the highest-impact optimizations you can make.
4. Prefer Decode Checks for UI, Header Checks for Diagnostics
If the goal is “should we render a fallback?”, Image() is usually enough and cheaper.
Use fetch() only when:
- You need to log or display specific failure reasons
- You’re debugging data quality issues
- You control the image origin and CORS headers
5. Defer Non-Critical Validation
If image validation isn’t blocking initial render:
- Run it after
DOMContentLoaded - Or schedule it with
requestIdleCallback
if ("requestIdleCallback" in window) {
requestIdleCallback(() => validateImages());
} else {
setTimeout(validateImages, 0);
}
This keeps the main thread responsive and improves perceived performance.
6. Avoid Revalidating Images Already On-Screen
If an <img> has already fired load, it’s already valid.
function isAlreadyLoaded(img) {
return img.complete && img.naturalWidth > 0;
}
Skip these entirely when scanning the DOM.
7. Know When Not to Validate on the Client
Client-side validation is great for UX, but it’s not always the right place to enforce correctness. Consider server-side or pipeline validation when:
- You ingest user uploads
- You generate image URLs programmatically
- You want to guarantee image correctness before it ever reaches the UI
This shifts work off the browser and reduces runtime complexity.
Common Issues and How to Debug Invalid Image Sources
Even with solid validation logic, image failures still happen. When they do, the fastest fix usually comes from knowing where to look first and which signals matter.
The Image Works in the Browser, but Validation Fails
The image renders correctly when used directly in an <img> tag, but JavaScript-based validation, especially checks using fetch(), fails unexpectedly. You may see network errors, missing headers, or an opaque response even though the visual output looks fine to users. These are most likely caused by CORS issues.
How to Debug:
- Open DevTools → Network → click the image request
- Check for
Access-Control-Allow-Origin - If you don’t control the image host, switch to an `Image()`-based validation instead of
fetch()
Status 200 Ok, but the Image Is Still Broken
The network request succeeds and returns a 200 OK status, yet the image shows a broken icon in the UI or triggers an onerror handler. From the outside, everything looks “successful,” but the browser refuses to render the image. This is likely caused by the Content-Type not being set to an image/*, a corrupted file, or a proxy layer returning HTML instead of an image.
How to Debug:
- Inspect the response headers in DevTools
- Look at the response preview: HTML usually stands out immediately
- Log
Content-Typewhen usingfetch()
The Image Fails Only in Production
Images load correctly in local development or staging environments but fail once deployed to production. The same URLs may appear valid, yet only production users report broken images or missing content. It’s likely caused by mixed content (such as an HTTP image on an HTTPS site), CDN rules, hotlink protection, or environment-specific URLS.
How to Debug:
- Check the browser console for mixed-content warnings
- Compare final request URLs after redirects
- Test the image URL directly in a new tab from the production domain
Intermittent Failures or “It Works on Refresh.”
Images appear broken at first but load correctly after a refresh, or failures occur sporadically with no obvious pattern. Users may report that images “sometimes” work, making the issue difficult to reproduce consistently. Most likely, this is caused by race conditions in dynamic rendering, cached failures, or blob URLS being revoked too early.
How to Debug:
- Log when and how
srcvalues are assigned - Disable cache in DevTools and hard refresh
- For blob URLs, confirm
URL.revokeObjectURL()isn’t called prematurely
Redirect Chains Breaking Image Loads
An image URL appears valid and reachable, but the request ultimately resolves to an unexpected destination, such as a login page or generic error response. In the UI, this shows up as a broken image even though the original URL looks correct. It’s commonly caused by expired signed URLs, auth-protected endpoints, or unexpected redirects.
How to Debug:
- Check
res.redirectedandres.urlwhen usingfetch() - Follow redirects manually in DevTools
- Confirm signed URLs haven’t expired
SVGs Behave Differently than Raster Images
SVG images technically load but report zero dimensions, fail layout calculations, or are incorrectly flagged as invalid by dimension-based checks. This often happens even though the SVG visually appears on screen, and can be caused by a missing viewBox, inline SVG vs external, or the SVG relying on CSS for sizing.
How to Debug:
- Inspect
naturalWidthandnaturalHeight - Open the SVG file directly and check its structure
- Treat SVGs as a special case when dimension checks matter
Validation Logic Itself Causing Performance Issues
Pages with many images feel sluggish, scroll performance degrades, or image-heavy views flicker during validation. In extreme cases, the UI may appear to freeze briefly while checks are performed. These can happen from too many concurrent checks, revalidating the same URLs constantly, or blocking validation in render-critical paths.
How to Debug:
- Add logging around validation calls
- Track how often the same
srcis checked - Move non-critical validation to idle time
Wrapping Up
Validating an image src in JavaScript isn’t about using one universal check. Sometimes we need to confirm that the browser can render an image; other times, we need insight into status codes, redirects, or content types. By combining browser-native loading checks with targeted diagnostics and graceful fallbacks, we can prevent broken images from degrading the user experience.
To reduce the frequency of runtime checks, it helps to manage images upstream. Platforms like Cloudinary ensure that uploaded assets are valid, optimizable, and delivered through reliable URLs, so the image sources we use in the browser are far less likely to break in the first place. Paired with lightweight client-side safeguards, this approach keeps image-heavy interfaces stable and performant at scale.
Frequently Asked Questions
Is Image() better than fetch() for checking image validity?
Neither is universally better. Image() is best when you want to know whether the browser can actually load and render the image, especially for cross-origin URLs. fetch() is better when you need diagnostic details like HTTP status codes or Content-Type, but it can be blocked by CORS and doesn’t guarantee the image will decode correctly.
Can I check if an image exists without downloading it?
Sometimes. A HEAD request with fetch() can confirm reachability and headers without downloading the whole image, but only if the server supports HEAD correctly and allows cross-origin access. In many real-world scenarios, the browser may still need to download at least part of the file to know whether it’s renderable.
Why does an image load in <img> but fail in fetch()?
This usually comes down to CORS. Browsers allow <img> elements to load many cross-origin images without special headers, but fetch() requires explicit permission from the server. If the image renders visually, it’s generally safe to treat it as valid for UI purposes.
Should I validate image URLs on the client or the server?
For user experience, client-side validation helps prevent broken layouts and awkward placeholders. For data integrity, especially when ingesting uploads or generating URLs at scale, server-side validation is often a better long-term solution. Many applications use both.