A thumbnail bug taught me to stop describing an image loader as “try the next URL.”
On a React page with lots of content, some thumbnails were blank after a reload. Navigating away and back made them appear. The URLs were valid. The failure came from timing between static markup, hydration, browser cache state, and a reused <img> element.
The first implementation had a fallback ladder:
AVIF -> WebP -> JPG -> failed
The ladder handled format support and missing files. It left three timing questions unanswered: what happens if an error arrives before React attaches its handler, the same error is observed twice, or an old asynchronous repair finishes after the element receives a new image?
I started treating the loader as a state machine.
One element, several generations
The loader keeps one <img> and changes its source as each candidate fails. Its state includes the current source triple, the candidate index, whether a purge is running, and whether the image has permanently failed.
The source triple matters because it identifies the AVIF, WebP, and JPG candidates together. When that triple changes, the loader resets its failure state before a new passive effect can inspect the element. That keeps an error from the previous image from advancing the new image’s ladder.
The browser can report the first failure before hydration. After hydration, an effect checks the existing element as well:
if (image.complete && image.naturalWidth === 0 && image.currentSrc) {
handleFailure(image.currentSrc);
}
The event handler and this check can observe the same failure. A ref keyed by the current source makes the operation idempotent. One broken request should advance the ladder once.
Purging needs an identity check
After every candidate fails, the loader asks the Cache API to remove the exact poisoned responses. It retries only when the purge removed something. If there is nothing to remove, another request is unlikely to change the result, so the loader gives up.
The purge is asynchronous. While it runs, the element may receive a different source triple or disappear from the page. A result from the old request must not change the new state.
The core idea is shown below as simplified pseudocode. The production implementation has additional guards:
const generationId = ++generation.current;
purgePoisonedPhoto(urls).then((removed) => {
if (generation.current !== generationId) return;
if (removed > 0) {
retry();
} else {
fail();
}
});
The same URL appearing again is still a new generation. That detail covers a sequence that is easy to miss: image A starts a purge, the element switches to B, then later receives A again. The old result belongs to the first A and cannot control the second one. Unmounting also invalidates the generation.
The tests follow the transitions
The most useful tests follow concrete scenarios: a failure before hydration, duplicate notifications for one broken request, a source triple changing during a purge, an unmounted element, and the same URL being reused after a change.
One test deliberately resolves the old purge after the new source is installed. The expected result is that the new source remains untouched. That test caught the bug that the happy path never saw.
The implementation became easier to review when each transition had an invariant:
- one source failure produces one transition;
- a new source triple starts fresh;
- only the current generation may retry or fail;
- a purge with no removal does not loop forever.
The list is short because the behavior is explicit. It also gives an agent a narrow task to work on. I can ask for one invariant and its failing scenario, then review the change without handing over the entire application’s context.
This applies beyond image formats. A fallback list can hide a state machine whenever a browser callback, cache operation, or hydration step arrives late. Naming the states makes the race visible, and transition tests keep it fixed.