Definition
To show an image on screen by feeding an <img> some data (a blob) that lives in browser memory, you create a temporary address with URL.createObjectURL(blob). If, inside a React component, you create this temporary address in useMemo and release it with URL.revokeObjectURL in a useEffect cleanup function, then across multiple renders you get an ordering tangle where an "already-discarded address is set on <img> again," and the image intermittently fails to appear. This document covers why that race (race condition) happens, and how images whose URL the server provides can eliminate the problem at its source by rendering directly by URL, with no blob or objectURL at all.
Here, objectURL is a temporary string address pointing to a blob (a chunk of binary data held in browser memory), and you must release it with revokeObjectURL for that data to be freed from memory. A race (race condition) is a state where the execution order of two operations is not guaranteed, so the result becomes erratic.
Why it matters
Start with the symptom. Every time you refresh, the image "sometimes shows and sometimes doesn't." There is no network error in the console. No matter how much you inspect the server or CDN, the file is fine. The cause is not the server but the client's render timing.
React deliberately renders components twice in development mode (StrictMode), and in concurrent rendering it may prepare a commit and then throw it away. When objectURL creation lives in useMemo (a computation cache) and release lives in useEffect (a cleanup hook), orderings such as "revoke in cleanup → set that URL on <img> again in the next render" get out of sync. When <img> points at an already-released blob address, a blank image appears. Because reproduction is erratic, this is especially hard to debug.
How it works
useMemo is merely a cache meant to avoid recomputing a value; it is not a tool for managing the lifecycle of creating and cleaning up. So if you put a side effect like createObjectURL in it, React does not guarantee when it will be released.
The crux is that creation and release live in different hooks, so they do not pair up with the commit/unmount cycle. In contrast, images served by the server (e.g., a proxy path like /media/photo/123) are already stable URLs, so the intermediate step of blob→objectURL conversion is unnecessary in the first place and becomes the source of the race.
Applied practice
Principle 1 — Render server-URL images directly, without objectURL.
Principle 2 — If objectURL is unavoidable, pair creation and release in the same effect.
Principle 3 — On prop change, reset state by comparing the previous prop during render, not in an effect.
Principle 4 — Make the load-failure fallback an <img onError> stage machine. <picture><source type="image/webp"> only looks at whether the browser supports webp and picks the source accordingly. If it is supported but that file 404s, it does not fall back. To fall back on an actual load failure, you must drive the webp → original → null order yourself with onError.
Trade-offs
- Direct URL rendering: the race disappears and you get browser/CDN/Service Worker caching as-is. In exchange, the server must be able to serve that image by URL (usually solved with a proxy route).
- objectURL: it is still the right answer when there is no network URL — file-selection previews, fully offline blobs, download triggers. In that case, always tie creation and release into a single effect.
When not to use it
- Wrapping an image the server already provides by URL in a blob — pure loss (race + no caching).
- Running side effects like
createObjectURL, subscriptions, or timers insideuseMemo/useCallback— the cleanup timing is not guaranteed.
Common mistakes
- Splitting creation into
useMemoand release intouseEffect→ intermittent breakage under StrictMode/concurrency. - Not releasing at all, so blobs accumulate in memory (leak).
- Handling state reset on prop change in an effect, causing extra renders and warnings.
- Assuming
<picture>will also handle 404 fallback.
Related concepts
- race-safe-async-ui-requests — the general race when resource lifetimes get entangled with render/async timing
- server-image-proxy-transcoding-cache — the proxy layer where the server provides stable image URLs
- ssr-hydration-mismatch — you cannot attach an onError fallback to server-rendered images, so keep the original