What this post covers
I collect three things I learned while catching an intermittent bug where an image "sometimes shows and sometimes doesn't" on every refresh. There is no specific-product story here — only general concepts that hold anywhere (React hook lifetimes, browser image conversion, service worker caching).
There was one core realization. This bug was not a server/network problem but a client-side render-timing problem. The file was fine; the screen was broken.
What I covered today
| Task | What I wanted to do | What I did | Result |
|---|---|---|---|
| objectURL race | Eliminate the intermittent broken images | Removed blob→objectURL and rendered the server URL directly | Race eliminated at source + cacheable |
| WebP fallback | Make images lighter but not break on failure | webp-first + onError fallback + a test-hang-prevention gate | Progressive enhancement, test-safe |
| Service worker shell | Do the offline document fallback safely | Scoped the shell fallback to navigate requests only | Removed image/API request pollution |
1. Don't wrap server-provided images in objectURL
What the situation was. The image broke erratically on every refresh. There was no network error in the console. At first I suspected an expired storage signed URL, but the images were coming down through a server proxy path, so it was not an expiry issue.
The core concept. The cause was an objectURL lifetime race. An objectURL is a temporary address pointing to image data (a blob) held in browser memory. You create it with URL.createObjectURL and discard it with URL.revokeObjectURL. But if you split this creation into useMemo (a computation cache) and the release into useEffect (a cleanup hook), then in development mode where React renders twice (StrictMode) or in concurrent rendering that throws a commit away, the order of "release → set that address on <img> again" gets out of sync. <img> points at an already-discarded blob address and a blank image appears. Because useMemo is not a tool for managing lifecycle but merely a computation cache, putting a side effect in it means the cleanup timing is not guaranteed.
What I did. Instead of the minimal patch (bundling creation and release into a single effect), I removed the objectURL entirely and rendered the server URL directly as <img src>. The server already provides a stable URL, so there was no reason to wrap it in a blob, and as a bonus I got browser/CDN/service-worker caching too. State reset on prop change I handled not with an effect but by comparing the previous prop during render (React's official escape hatch).
The lesson. Render images the server provides by URL directly, do not wrap them in an objectURL blob — the race disappears at its source.
→ objectURL Lifetime & Revocation Race
2. Browser-only libs can hang your tests
What the situation was. To make images lighter I introduced WebP (a format that packs the same quality into a smaller file). On upload I stored both the original and the WebP, and the viewer was to prefer WebP but fall back to the original on failure. But the WebP conversion library depends on the browser's canvas, and the test environment (jsdom) has no canvas, so the conversion risked waiting forever on an image load and hanging.
The core concept. I learned two things. First, when you put a browser-only lib on a code path that tests run, you need a synchronous capability gate. Before starting the heavy work, check something like canvas.getContext('2d') != null immediately (without awaiting anything), and if it's absent, give up right away with null so jsdom does not hang. Second, the load-failure fallback must be an <img onError> stage machine, not <picture><source type="webp">. <picture> only looks at whether the browser supports WebP when choosing a source; it does not fall back even if that file 404s. To fall back on an actual load failure, you must drive the webp → original → null order yourself with onError.
What I did. I made the conversion wrapper check canvas support synchronously before running, so jsdom/server are filtered out to null immediately (no test hang), and I implemented the viewer fallback as an onError stage machine.
The lesson. When putting a browser-only API on a test path, make it a no-op immediately with a synchronous capability gate to prevent hangs, and do the load-failure fallback with onError, not <picture>.
3. The service worker shell fallback is for document requests only
What the situation was. The production service worker had a separate bug. It was written to intercept every same-origin GET and, on failure, fall back to the app-shell HTML ("/"), so when photo/API requests failed it returned HTML, breaking <img> and piling that HTML endlessly into the cache.
The core concept. A service worker is a background script that intercepts requests between the page and the network. The offline "shell fallback" (returning skeleton HTML when the network fails) is useful, but it must be applied only to document-navigation requests where request.mode === 'navigate'. request.mode is the marker distinguishing whether the request is a page navigation or a subresource like an image. Apply the shell to every request without checking this, and you return HTML on image failures, breaking the render.
What I did. I scoped the shell fallback to navigate requests, and branched images/APIs/static assets to their own strategies (image cache, API network). Existing pollution I purged by bumping the cache version.
The lesson. The service worker shell fallback is navigation-document-request only — don't return HTML for image/API requests.
→ Service Worker Shell Fallback for Navigation Requests Only