
Broken Image Fix: The Complete Guide to img onerror and Image Fallbacks
Fix broken images with the img onerror handler, capture-phase listeners, placeholder swaps, React and Next.js fallbacks, and Service Worker recovery. Why images fail, what broken images cost your UX and SEO, and how to prevent failures before deploy.
Every image on your page is a network request, a decoder call, and a layout decision. Any of the three can fail. When it does, users see the broken-image icon, alt text spills out unstyled, and your layout collapses around a hole. A broken image fix strategy is not optional polish. It is the difference between a page that degrades gracefully and a page that looks abandoned.
The standard tool is the onerror attribute on the <img> tag, plus a small set of JavaScript and CSS techniques that catch the cases onerror alone misses. This guide covers why images fail, what failures cost you, every recovery pattern from inline handlers to Service Workers, and how to prevent failures before they reach production.
Why Images Fail
Images break for reasons that have nothing to do with your code being wrong at the time you wrote it. The image was fine on Tuesday. The rename happened on Thursday.
| Cause | Typical trigger | Frequency in production |
|---|---|---|
| 404 after rename or move | Content edits, CMS migrations, refactors | Most common by far |
| Expired CDN or signed token | Signed URLs with short TTLs, token rotation | Common on media-heavy sites |
| Network flake | Mobile handoffs, captive portals, flaky Wi-Fi | Sporadic, hard to reproduce |
| CORS rejection | Cross-origin images drawn to canvas, missing headers | Common in canvas and WebGL apps |
| Decoder failure | Corrupt file, truncated upload, unsupported format | Rare but permanent |
| Wrong MIME type | Server serves text/html for a missing image |
Common misconfiguration |
| Hotlink protection | Third-party host blocks your referrer | Common when embedding others’ images |
The 404-after-rename case deserves emphasis because it is self-inflicted and silent. A designer renames hero-v2.jpg to hero-final.jpg, updates the page, and misses one reference in a template partial or a JSON data file. The page ships. The image 404s. Nothing in your test suite notices because the HTML parses fine.
Expired CDN tokens fail on a delay, which makes them worse. The page works in staging, works for the first hour after deploy, then every signed URL dies at once. If you serve images through a CDN with token auth, your fallback strategy is your only defense after the token expires. The guide on image caching headers covers how stale-while-revalidate keeps an old copy alive during exactly this kind of failure.
Decoder failures are the rarest and the most permanent. A truncated JPEG from a flaky mobile upload, a corrupt WebP from a bad transcode, a HEIC that reached a browser without support — the bytes arrive, the decoder refuses, and onerror fires even though the network request succeeded. No amount of retrying fixes a file the browser cannot decode.
What Broken Images Actually Cost
It is tempting to treat a broken image as cosmetic. The evidence says otherwise.
Trust. Studies of e-commerce behavior consistently show that product pages with broken images convert far below pages with complete media. A broken image on a product page reads as “this store is not maintained.” Users do not distinguish between a missing image and a missing business.
Layout. An <img> without valid dimensions and without a loaded source collapses to the size of its alt text, or to zero height. Content below jumps when the failure happens, which is a layout shift and a Core Web Vitals problem. If you already set explicit width and height attributes — and you should — the space is reserved and the damage is contained to a broken icon inside a correctly sized box. That is the cheapest broken image fix available: reserve the box.
SEO. Google does not directly penalize a broken image the way it penalizes a broken page. But images are a ranking surface through Google Images, and a page that serves 404s for its images loses that traffic. Broken resources also slow crawling and waste crawl budget on a large site. Treat these as soft signals: they will not sink a page alone, but they compound with every other weakness.
Support load. Every broken image generates a support ticket or a silent exit. Neither shows up in your analytics as the image problem it is.
The Inline onerror Handler
The fastest image fallback is one attribute:
<img
src="/images/hero.jpg"
alt="Product hero"
onerror="this.onerror=null; this.src='/images/fallback.jpg';"
width="1200"
height="600"
/>
Two things happen in that handler, and both matter.
The swap. this.src='/images/fallback.jpg' replaces the failed source with a known-good image. Pick something small, always present, and visually neutral — a gray placeholder with your logo, or a generic gradient. It must live at a path you control and never move.
The null. this.onerror=null removes the handler before the swap. Without it, a failing fallback image re-triggers onerror, which swaps in the fallback again, which fails again, which triggers onerror — an infinite loop of failing requests. Browsers eventually stop, but not before they have hammered your server with repeated 404s for the fallback path and locked the main thread in a retry storm. Nulling the handler is the entire difference between a robust fallback and a self-inflicted denial of service.
The inline attribute works everywhere and needs no JavaScript architecture. Its limits are real, though:
- It only catches the load failure of that one image. It cannot coordinate a page-wide response.
- Inline handlers violate strict CSP policies that disallow inline script. If your site ships
Content-Security-Policywithout'unsafe-inline', the attribute silently does nothing. - The fallback is per-image boilerplate. On a site with thousands of images, you want one delegated listener, not thousands of attributes.
addEventListener with Capture: Error Events Do Not Bubble
For page-wide coverage, attach one listener and let it catch every image failure. The critical detail: error events do not bubble. A standard addEventListener('error', ...) on document will never see an image failure, because the event stops at the element.
The fix is the capture phase. Capture-phase listeners run on the way down the DOM, before the event reaches its target, so they see non-bubbling events:
document.addEventListener('error', (event) => {
const img = event.target;
if (!(img instanceof HTMLImageElement)) return;
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = 'true';
img.src = '/images/fallback.jpg';
}, true); // <-- capture: true is mandatory
Walk through the guard clauses, because each one prevents a known failure mode:
- Type check. The capture listener also sees
errorevents from<script>and<link>tags. Without theHTMLImageElementcheck you would try to set.srcon a stylesheet and throw. - Idempotence flag. If the fallback image itself fails — server hiccup, ad blocker, offline — the listener would fire again for the fallback and swap it with itself, looping. The
dataset.fallbackAppliedflag makes the handler run at most once per image.
This pattern covers every image on the page, works under strict CSP, and needs one line of setup. It is the recommended default for server-rendered and static sites.
One more capture-phase trick: window.addEventListener('unhandledrejection', ...) and the capture error listener together give you a page-wide net for both image failures and script failures. Keep them separate; their recovery actions differ.
Swapping to a Styled Placeholder Div
A fallback image still looks like an image. Sometimes the better recovery is to remove the failed <img> entirely and replace it with a styled <div> — a branded placeholder card, a gradient, or a “photo unavailable” tile that matches your design system.
function replaceWithPlaceholder(img) {
const box = document.createElement('div');
box.className = 'img-placeholder';
box.style.width = img.width + 'px';
box.style.height = img.height + 'px';
box.setAttribute('role', 'img');
box.setAttribute('aria-label', img.alt || 'Image unavailable');
img.replaceWith(box);
}
document.addEventListener('error', (event) => {
const img = event.target;
if (!(img instanceof HTMLImageElement)) return;
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = 'true';
replaceWithPlaceholder(img);
}, true);
.img-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e5e7eb, #d1d5db);
color: #6b7280;
font-size: 0.875rem;
border-radius: 8px;
}
.img-placeholder::after {
content: "Image unavailable";
}
Why replace the node instead of styling the broken image? Because the broken-image rendering is browser-owned. Chrome, Firefox, and Safari each draw the failure differently, and you cannot fully restyle it. Replacing the node gives you a predictable, brand-consistent surface in every browser.
The role="img" and aria-label matter: screen readers announced the original image’s alt text, and the replacement should announce something equivalent rather than disappearing from the accessibility tree. If accessibility in image fallbacks matters to your product, the guide on image accessibility mistakes covers the broader picture.
The CSS-Only Approach: Honest Caveats
You will find CSS snippets that claim to fix broken images with no JavaScript. The most common one targets the alt text:
img::before {
content: attr(alt);
/* ... */
}
The idea: when an image fails, browsers render the alt text, so style it. Here is the honest assessment. This is unreliable and you should not depend on it.
- Chrome and Firefox apply
::before/::afterto broken images only in specific conditions, and Chrome’s behavior has changed across versions. - Safari historically does not apply generated content to broken images at all.
- When the image loads, some browsers have shown generated content alongside the image in edge cases.
- The
altattribute only renders when the image has a non-empty alt and the failure is a load failure — decoder failures and zero-sized images behave differently.
There is a variant that works more consistently for the specific case of hiding the alt text spill:
img {
/* Broken image alt text inherits these in most browsers */
font-size: 0;
color: transparent;
}
But this hides information from users who needed the alt text, and it does nothing about the broken icon. CSS alone cannot swap a source, cannot know the image failed versus is still loading, and cannot coordinate with analytics. Use CSS as a cosmetic supplement to the JavaScript handlers above, never as the primary broken image fix. The JavaScript patterns are a few lines and they work everywhere.
Framework Approaches
React: onError with State
React wraps the inline handler in a prop and gives you state to drive a real UI swap:
import { useState } from 'react';
function SafeImage({ src, alt, width, height, fallback = '/images/fallback.jpg' }) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div className="img-placeholder" style={{ width, height }} role="img" aria-label={alt || 'Image unavailable'}>
Image unavailable
</div>
);
}
return (
<img
src={src}
alt={alt}
width={width}
height={height}
onError={() => setFailed(true)}
loading="lazy"
/>
);
}
The state swap is the React-native version of the placeholder-div pattern: on failure, the <img> unmounts and a styled component takes its place. No retry loops are possible because onError fires once per source, and the failed image is gone after the state change.
Two refinements worth adding for production use:
- If
srccan change (a carousel, a search grid), reset the failure state when the source changes, or a previously failed component stays failed forever:useEffect(() => setFailed(false), [src]). - If the failure is transient (network flake), one automatic retry with a cache-busting query param recovers a meaningful share of failures:
onError={() => setRetryCount(c => c + 1)}withsrc={src + '?r=' + retryCount}capped at one or two attempts.
Next.js Image: the fallback prop pattern
next/image does not ship a built-in fallback prop. The established pattern wraps it with the same state approach:
'use client';
import { useState } from 'react';
import Image from 'next/image';
function FallbackImage({ src, alt, width, height, fallback = '/images/fallback.jpg', ...rest }) {
const [src, setSrc] = useState(src);
return (
<Image
{...rest}
src={src}
alt={alt}
width={width}
height={height}
onError={() => setSrc(fallback)}
/>
);
}
Note the guard this pattern still needs: if the fallback itself fails, onError fires again with src already equal to the fallback. Add a check — if (src !== fallback) setSrc(fallback) — and the loop is closed. The same one-swap-only discipline from the vanilla handler applies in every framework. The Next.js image guide covers the loader and priority options that surround this pattern.
For React Server Components, the error boundary does not help — image load failures are not React errors. The client-side onError handler is the only hook you have.
Service Worker Fallback: Serve Stale on Failure
The strongest recovery layer sits below the page entirely. A Service Worker intercepts every image fetch and can serve a cached or stale copy when the network fails:
// sw.js
const IMAGE_CACHE = 'images-v1';
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
const isImage = event.request.destination === 'image' ||
/\.(jpe?g|png|webp|avif|gif|svg)$/i.test(url.pathname);
if (!isImage || event.request.method !== 'GET') return;
event.respondWith(
(async () => {
const cache = await caches.open(IMAGE_CACHE);
try {
const response = await fetch(event.request);
// Opacity: cache a copy for future failures
if (response.ok) cache.put(event.request, response.clone());
return response;
} catch (err) {
// Network failed: serve any cached copy, even stale
const cached = await cache.match(event.request, { ignoreSearch: false });
if (cached) return cached;
// Last resort: generic placeholder
const placeholder = await cache.match('/images/fallback.jpg');
if (placeholder) return placeholder;
throw err;
}
})()
);
});
This recovers the entire class of transient failures — network flake, momentary CDN errors, captive portals — without the page knowing anything happened. The user sees the image they saw last time instead of a broken icon.
The stale copy trade-off is usually correct for images: an outdated hero is invisible to users; a missing hero is not. Pair this with stale-while-revalidate cache headers at the CDN layer so even users without the Service Worker get old-but-present images during deploys and token expiries. The image caching headers guide explains the header configuration; this Service Worker is the client-side complement to it.
One caution: the Service Worker adds a placeholder to its cache at registration time. Make sure the placeholder URL is cached during the install step (cache.add('/images/fallback.jpg')), or the last-resort match fails exactly when you need it.
Monitoring: Catch Failures Before Users Report Them
Every pattern above reacts to a failure a user is already seeing. Monitoring shortens that window.
Server logs. Your access logs already contain every 404. A nightly pass over the log for GET /images/ 404 entries, grouped by path and referrer, produces a ranked list of broken references — usually a handful of culprits responsible for most of the volume. This is the cheapest monitoring that exists and it catches the rename-after-deploy class of failure within a day.
Sentry and error trackers. Image load failures do not throw JavaScript exceptions, so they never appear in a default error feed. Capture them explicitly with the same capture-phase listener, sending a breadcrumb or a custom event instead of swapping the source:
document.addEventListener('error', (event) => {
const img = event.target;
if (!(img instanceof HTMLImageElement)) return;
Sentry.captureMessage(`Image failed to load: ${img.currentSrc}`, {
level: 'warning',
extra: { page: location.pathname, alt: img.alt },
});
}, true);
Run this listener in monitoring mode for a week before enabling the swap, and you will have a data-backed list of which images actually fail in the wild, on which pages, at what rate. The browser DevTools image debugging guide covers how to trace an individual failing request once monitoring points you at it.
Synthetic checks. A scheduled headless-browser run against your top pages, asserting that no <img> has complete === false && naturalWidth === 0, catches failures before real traffic does. This is exactly the kind of check that belongs in your CI pipeline rather than a cron job on someone’s laptop.
Prevention: Stop Shipping Broken References
Recovery is for failures you could not prevent. Most image failures are preventable at two points.
CI link-checking against built HTML. After the build, crawl the generated HTML, extract every src, srcset, and CSS url(), and issue a HEAD request for each. Fail the build on any non-200. This catches renames, typos, and deleted assets before deploy, when the fix is a one-line diff instead of a production incident. The automated image QA testing guide shows how to structure these checks, and the CI/CD image optimization pipelines guide shows where the check belongs in the pipeline.
Deploy-time asset manifest verification. If your build produces an asset manifest (most bundlers do), add a verification step that resolves every image reference in your content and templates against the manifest. A reference that resolves to nothing fails the deploy. This catches the case where the link-checker’s target URL exists but the file behind it was replaced by a different build.
Stale-while-revalidate and long cache lifetimes. Configure Cache-Control: public, max-age=31536000, stale-while-revalidate=86400 on immutable image URLs. When a deploy or a token rotation briefly breaks the origin, the CDN keeps serving the last good copy instead of propagating the failure. Combined with content-hashed filenames, this makes most deploy-related image failures impossible rather than merely recoverable.
Decision Table: Match the Fix to the Failure
| Failure cause | Best recovery | Best prevention |
|---|---|---|
| 404 after rename | Capture-phase listener → fallback swap | CI link-checker on built HTML |
| Expired CDN token | Service Worker stale copy | Long TTLs + stale-while-revalidate headers |
| Network flake | Service Worker cache; one retry with cache-buster | Preload critical images; SW precache |
| CORS rejection | Fallback image swap (canvas draw will throw) | Correct crossorigin attr + server headers |
| Decoder failure | Placeholder div replacement | Validate uploads at ingest |
| Wrong MIME type | Fallback swap; alert via monitoring | Server config check in CI |
| Hotlink protection | Self-host the image | Never hotlink; download and serve |
Read the table left to right as a triage flow: monitoring tells you the cause, the middle column limits the user-facing damage today, the right column makes it not happen again.
Putting It Together
A complete image fallback stack has four layers, and each is cheap:
- Reserve space.
widthandheighton every image, so failure never shifts layout. - React per image. The capture-phase listener with a one-shot fallback swap, or the React
onErrorcomponent in framework code. - Recover network failures. A Service Worker serving stale copies for transient outages, backed by
stale-while-revalidateheaders at the CDN. - Prevent and observe. CI link-checks and manifest verification to stop failures from shipping; log analysis and error-tracker breadcrumbs to catch what slips through.
The inline onerror="this.onerror=null; this.src='...'" handler remains the right answer for a single image on a simple page. Everything else in this guide scales that idea: fail once, recover visibly, and never loop. Build the layers in order — space reservation first, then the listener, then the Service Worker, then prevention — and broken images stop being incidents and become a handled edge case your users never see.