Fallback images that fit: handling broken images with a service worker
A broken image is never the right shape
On a content-heavy commerce site, some images will fail. Editors delete assets that are still referenced, CDN edges have bad moments, and users lose signal halfway through a page. The browser's answer is a broken-image icon and whatever alt text fits, sitting in a box whose dimensions were set for the image that's meant to display there.
The obvious fix is a placeholder image. The trouble is that a single placeholder has a single aspect ratio. Drop a square logo into a 21:9 hero and it stretches; let object-fit: contain save it and you get a tiny mark in a sea of blank space. Every slot on the page wants a placeholder of its own shape, and a site with responsive srcset candidates, or artworking using picture elements have dozens of shapes.
The right place to solve this is between the page and the network, and a service worker is the only thing that lives there. It sees every image request the browser makes, knows the width and height that were asked for, and can answer with bytes of its own rather than whatever the server sent. It also runs on its own thread. Generating a placeholder on the main thread means decoding, scaling and painting while the user is trying to scroll, and on a page with several broken images that is a visible stutter. In a service worker the same work happens off the main thread, before the page ever sees a response, so the layout stays still and the interaction stays smooth.
It is also the more scalable option. The alternative is a library of placeholders, one per aspect ratio, that designers have to produce, developers have to wire to every slot, and both have to revisit whenever a layout changes. Using a service worker instead means one small logo and no placeholder maintenance at all: the worker produces a fallback that matches each failed request's dimensions, then keeps it for the next time the same size is asked for.
The approach at a glance

A failed request is caught in one of two places. When the service worker can see that a response failed, it produces the fallback at the size encoded in the URL and the page is none the wiser. When the failure is one the worker cannot see, the browser's onerror fires instead, the component requests /fallbackImage with the rendered size, and that request re-enters the worker, which produces the same fallback.
The client side: turning an error into a request the worker understands
Every image on the site renders through a thin wrapper component called ImageWithFallback. Its only job is to listen for onError and, when a service worker is in control, it'll swap the source for a URL that encodes the size the image is actually being shown at.
This component should not need to exist. If the service worker could see that the CDN answered with a 404, it would hand the request to the fallback path on its own and the page would never know. The reason it cannot is where the images come from. Our front end runs on one domain and the CMS serves media from another, through a CDN we do not control, so every image is a cross-origin request made without CORS. The browser answers those with an opaque response: the bytes arrive and the picture can be displayed, but the script that fetched it is not allowed to look inside. The status reads 0, the headers are empty, and the body is unreadable. A service worker can store and forward an opaque response, but it cannot tell a 404 from a 200.
The browser itself still knows the image failed to decode, and onError is the only place that knowledge surfaces. So the page has to turn it into a request the worker can act on. It is a workaround for opaque responses, not a design choice.
"use client";
import Image, { ImageProps } from "next/image";
import { forwardRef, useCallback, useEffect, useState } from "react";
export const ImageWithFallback = forwardRef<HTMLImageElement, ImageProps>((props, ref) => {
const { src, alt, ...rest } = props;
const [imageSrc, setImageSrc] = useState(() => src);
const handleFbImage = useCallback((err: React.SyntheticEvent<HTMLImageElement, Event>) => {
const img = err.target as HTMLImageElement;
if (navigator.serviceWorker.controller) {
setImageSrc(`${window.location.origin}/fallbackImage?width=${img.offsetWidth}&height=${img.offsetHeight}`);
}
}, []);
useEffect(() => {
setImageSrc(src);
}, [src]);
return <Image src={imageSrc} ref={ref} alt={alt} onError={handleFbImage} {...rest} />;
});
The size comes from the layout because the request is out of reach. offsetWidth and offsetHeight are the pixels the broken image occupies on screen, after CSS has done its work. In a service worker-only implementation we would not need them: every image URL already carries width= and height= in the options Cloudflare uses to resize it, and the worker can read those straight off the failed request. That is in fact what it does for same-origin failures. The page only measures the element because, for an opaque failure, the worker never gets the chance to look at the original URL, so the component has to send the size along in its own request. The URL is a message, and not an actual resource. There is no /fallbackImage route on the server. The path exists so the service worker can recognise the request and read the dimensions from its query string. Our Cloudflare image loader also skips any URL containing fallbackimage, so the request is never routed through the CDN. If there is no service worker, which we look for by querying if navigator.serviceWorker.controller is null, the handler does nothing and the browser shows its default broken-image state. The feature degrades to exactly what you would have had without the placeholder swap.
The page also tells the worker about the device pixel ratio, since a service worker has no window to ask. A small client component registers the worker and posts the ratio once it is in control:
const sendPxRatio = () => {
navigator.serviceWorker.controller?.postMessage(
JSON.stringify({ pixelAspectRatio: window.devicePixelRatio })
);
};
navigator.serviceWorker.register("/serviceWorker.js").then(() => {
if (navigator.serviceWorker.controller) {
sendPxRatio();
} else {
navigator.serviceWorker.addEventListener("controllerchange", sendPxRatio);
}
});
The worker keeps the ratio in a module-level variable and uses it to scale the logo so it stays crisp on high-density screens.
The service worker side: catching the failure and answering it
The worker ships one tiny asset, a 1 KB AVIF of the logo, and precaches it as part of the app shell during install. Everything else is produced at runtime.
Catching the failure
Every GET for a non-document resource flows through one fetch handler. When the network answers with a 4xx or 5xx, or throws, and the request was for an image or video, it hands off to the fallback path instead of returning the error. Trimmed to that behaviour, it looks like this:
const fetchOrFallback = async (req: Request, cache: Cache): Promise<Response> => {
const isMedia = req.destination === "image" || req.destination === "video";
try {
const resp = await fetch(req);
if (resp.status >= 400 && isMedia) {
return imageError(cache, resp, req);
}
return resp;
} catch (err) {
if (isMedia) {
return imageError(cache, null, req);
}
throw err;
}
};
imageError fetches the logo from the cache, or the network if the shell cache was somehow lost, and passes it to the next step along with the original request.
Reading the size out of the URL
The fallback needs a width and height. Both of the request shapes that reach this code carry them: the Cloudflare URL from the image loader has width= and height= in its options segment, and the client's /fallbackImage URL has them as query parameters. One pair of regular expressions covers both, with a square 240px default when only one dimension is present:
const widthRegex = /(width|originalWidth)=(\d*)/;
const heightRegex = /(height)=(\d*)/;
const getHeightWidth = (str: string) => {
const size = { width: 240, height: 240 };
const decoded = decodeURIComponent(str);
const widthMatch = decoded.match(widthRegex);
let valuesSet = 0;
if (widthMatch) {
size.width = Number(widthMatch[2]);
valuesSet++;
const heightMatch = decoded.match(heightRegex);
if (heightMatch) {
size.height = Number(heightMatch[2]);
valuesSet++;
}
}
if (valuesSet < 2) size.height = size.width;
return size;
};
Drawing the fallback with OffscreenCanvas
Now the worker has a logo and a target size, and it needs to turn them into an image. A service worker has no DOM, so there is no canvas element to draw on, no image element to decode into, and no way to call the APIs a page would normally use to build a picture.
What a worker does have is OffscreenCanvas and createImageBitmap. The first is a canvas with no document attached, with the same 2D context a page would get. The second decodes a blob into a bitmap without needing an element to hold it. Between them they are everything a page would use to compose an image, minus the page. The fallback is not a file we ship, it is a picture the worker paints on demand, in the exact shape the request asked for.
The logo is scaled to a fifth of the shorter side, clamped between 48px and 192px, then centred on a white canvas of the requested size:
const constructFallbackImage = async (req: Request, fbImage: Response, cache: Cache): Promise<Response> => {
const { width, height } = getHeightWidth(req.url);
const cacheKey = `${fallbackImageSrc}?width=${width}&height=${height}`;
// Already drawn this shape? Serve it.
const cached = await cache.match(cacheKey);
if (cached) return cached;
const bitmap = await self.createImageBitmap(await fbImage.blob());
// Scale the logo relative to the box, in device pixels
const pxW = Math.ceil(width * pixelAspectRatio);
const pxH = Math.ceil(height * pixelAspectRatio);
const sizeMax = Math.ceil(192 * pixelAspectRatio);
const sizeMin = Math.ceil(48 * pixelAspectRatio);
let logoSize = Math.min(pxW, pxH) * 0.2;
logoSize = Math.min(logoSize, sizeMax);
if (logoSize < sizeMin && width > sizeMin + 10 && height > sizeMin + 10) {
logoSize = sizeMin;
}
logoSize = Math.ceil(logoSize);
const logo = new OffscreenCanvas(logoSize, logoSize);
logo.getContext("2d")!.drawImage(bitmap, 0, 0, logoSize, logoSize);
// Compose onto a canvas of the requested size
const out = new OffscreenCanvas(width, height);
const ctx = out.getContext("2d")!;
ctx.fillStyle = "white";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(logo, (width - logoSize) / 2, (height - logoSize) / 2);
const blob = await out.convertToBlob();
return attemptCache(cache, req.destination, cacheKey, new Response(blob));
};
The result is a PNG the exact size of the hole it fills, so object-fit has nothing to stretch and the layout does not shift. Because the cache key includes the dimensions, the second broken image of the same shape costs nothing to draw.
Details that matter
The logo is a shell asset. It is added to the cache during install alongside the SVG sprite, so it is available offline and never has to be fetched at the moment an image fails, which is often the moment the network is the problem.
Drawn fallbacks are kept, under quota control. Before a drawn fallback is stored the worker checks navigator.storage.estimate() and, if usage is over its ceiling, evicts the oldest entry first. Fallbacks are small PNGs, but they obey the same rule as everything else the worker stores, so a run of broken images can never push the origin over quota.
Device pixel ratio is respected, up to a point. The logo is scaled in device pixels so it stays sharp on a retina screen, but the output canvas is created at CSS pixel size. That is deliberate: the fallback only needs to be as large as the box, and a 2x canvas would double its storage footprint for no visible gain on a flat white background.
The size parsing is shared with the Cloudflare loader. The same width= and height= tokens appear in both the CDN URL format and the client's fallback request, so one function reads both. A side effect is that when the focal-image loader encodes originalWidth, the regex still matches, which is why the pattern reads (width|originalWidth).
Only one fallback exists per shape. Because the cache key is the dimensions, a product grid with forty broken thumbnails of the same size draws once and serves from cache thirty-nine times.
Trade-offs and caveats
The onError hook is a workaround to opaque responses. With readable responses the worker would catch a CDN 404 itself and serve the fallback in the same round trip, and the wrapper component would not be needed at all. Because cross-origin images arrive as opaque responses with status 0, the worker cannot tell a failure from a success, the page receives a broken image, and only onError knows. The swap to /fallbackImage exists to carry that knowledge back to the worker, at the cost of one extra request. If CORS headers ever become available on the CDN, this hook and the client-side route can both be removed.
A service worker is the one place on the client that sees every image request, knows the size that was asked for, and can produce bytes without a DOM. That makes it a better home for fallback images than the component tree, where each slot would need its own placeholder and its own error handling. This could be acheived all client side, but this would cause render blocking when the images are created, doing the work in the service worker is non render blocking and better for performance.
The pieces are small. One precached logo, a regex to read dimensions from a URL, forty lines of OffscreenCanvas, and a component that turns onerror into a request the worker recognises. Together they mean a broken image never changes the shape of the page, and the user sees a tidy branded box rather than a torn-corner icon.
The lesson that generalised for us is to treat a URL as a message channel between the page and the worker. The /fallbackImage route does not exist on the server and never needs to. It exists so the client can tell the worker what it wants, in a form that survives caching, and the worker can answer with something it made itself.
Comments