If a tool that reads a file works once and then fails on every subsequent
change, check your cleanup effect before you check the file. A useEffect
whose dependency array contains the object URL its cleanup revokes does not
run on unmount. It runs on every change, and it destroys the URL you are
still reading from.
The fix is three lines:
- Hold the object URL in a
useRef, not in state you also depend on. - Revoke the old URL at the moment you replace it, not in an effect.
- Give the cleanup effect an empty dependency array so it only fires on unmount.
Here is the full version, including the wrong turn that cost us a release.
What the bug looked like
Our browser image compressor threw a toast reading Failed to load image.
It happened on a plain JPEG straight off a camera, roughly 300 milliseconds
after the first compression completed successfully. No click required. Select
a file, watch it compress correctly once, then watch it break on its own.
The versions involved were Next.js 16.0.10 and React 19.2.0. The bug shipped on 2026-07-10 and was fixed on 2026-08-24, so it was live for 45 days. Anyone who used the tool in that window hit it the moment they touched the quality slider.
Why "it must be the file format" was the wrong answer
The first hypothesis was HEIC, and it was a good hypothesis. macOS reports
HEIC photos with a MIME type of image/heic, which sails straight through a
file.type.startsWith("image/") check. Chrome, Firefox and Edge cannot decode
HEIC at all, so an <img> fed one fires onerror, which is exactly the code
path that produced the message. TIFF fails identically.
So we shipped a fix for that: sniff the container bytes on intake, decode HEIC through a WebAssembly build of libheif, reject TIFF with an explanation. All useful work. None of it touched the actual bug.
The reply came back in five words: it's just a jpg file.
The tell was there the whole time and we walked past it. A format problem fails on the first attempt. This failed on the second. A file the browser cannot decode never decodes, so a successful first compression ruled out the format before we started. Any hypothesis that cannot explain "worked once" was disqualified from the beginning.
That is the part worth stealing from this post. When something works exactly once, stop looking at the input and start looking at what changed between attempt one and attempt two.
What actually broke
Here is the offending effect, comment included:
// Clean up urls on unmount
useEffect(() => {
return () => {
if (originalUrl) URL.revokeObjectURL(originalUrl);
if (compressedUrl) URL.revokeObjectURL(compressedUrl);
};
}, [originalUrl, compressedUrl]);
The comment says unmount. The dependency array says otherwise. React runs an
effect's cleanup whenever its dependencies change, not only when the component
unmounts, so this cleanup fires on every single change to either URL. And what
it does when it fires is revoke originalUrl, which is the source image the
tool is still using.
The sequence:
- A file is selected.
originalUrlbecomesblob:A. - The first compression reads
blob:A, produces output, setscompressedUrltoblob:B. - That state change makes the dependencies differ, so React runs the previous cleanup. It revokes
blob:A. compressedUrlwas also in theuseCallbackdependency list for the compression function, so that callback was recreated, the debounced effect re-fired, and it ranimg.src = blob:Aagainst a URL that no longer exists.onerror.Failed to load image.
Step 4 is what turned a latent leak into a visible failure. Without it the URL would still have been revoked, but nothing would have tried to read it again until the user moved a slider. With it, the component attacked itself unprompted.
How to reproduce it without the app
You do not need the component to prove this. React's contract is simple enough to emulate: when dependencies change, run the previous cleanup, then register the new effect. That is enough to reproduce the bug against real object URLs in a real browser.
function makeEffectSlot() {
let cleanup = null, deps = null;
return (nextDeps, fn) => {
const changed = !deps || nextDeps.some((d, i) => d !== deps[i]);
if (!changed) return;
if (cleanup) cleanup();
deps = nextDeps;
cleanup = fn();
};
}
const canLoad = (url) => new Promise((res) => {
const i = new Image();
i.onload = () => res(true);
i.onerror = () => res(false);
i.src = url;
});
Drive the old lifecycle through it and the source is gone after one success:
ok old lifecycle: source loads before the first compression
ok old lifecycle: source is destroyed by the first success sourceLoads=false
Drive the fixed lifecycle through ten consecutive compressions, the way holding an arrow key on a quality input behaves, and both the source and the current output survive while every superseded output is released:
ok fixed lifecycle: source survives ten compressions
ok fixed lifecycle: current output is alive
ok fixed lifecycle: every superseded output was revoked stale=9 leaked=0
That last line matters. It is easy to fix a premature revoke by simply never revoking, and then you have swapped a broken tool for a memory leak. The test asserts both directions.
How to fix it
The root cause is a category error. An object URL looks like a string, so it gets stored like a string, in state. But it is not a value. It is a handle to a resource that pins the underlying file in memory until you release it, and its lifetime has nothing to do with your render cycle.
Treat it as a resource:
const originalUrlRef = useRef("");
const compressedUrlRef = useRef("");
// Empty deps: this really does only run on unmount.
useEffect(() => {
return () => {
if (originalUrlRef.current) URL.revokeObjectURL(originalUrlRef.current);
if (compressedUrlRef.current) URL.revokeObjectURL(compressedUrlRef.current);
};
}, []);
Then revoke at the point of replacement, which is the only moment you actually know the old URL is finished with:
if (compressedUrlRef.current) URL.revokeObjectURL(compressedUrlRef.current);
const optimizedUrl = URL.createObjectURL(blob);
compressedUrlRef.current = optimizedUrl;
setCompressedUrl(optimizedUrl);
Keep the URL in state as well if you need to render it. The ref is not replacing state, it is giving the cleanup something stable to read that does not drag the URL into a dependency array.
The last change was removing compressedUrl from the compression callback's
dependencies, so a successful pass stops scheduling another one.
Where else this hides in a codebase
Having found it once, we grepped for every createObjectURL in the project.
The audit found a second instance with the same root cause and a milder
symptom.
A QR code generator created an object URL for an uploaded logo and never revoked it. Not when the logo was replaced, not when the user clicked Remove Logo, and not on unmount. Every logo someone tried stayed pinned in memory for the life of the tab. No error, no visible failure, just a leak.
That one had a near miss worth noting. Its history is persisted to
localStorage, and it stores only the data and the type. Had it stored the
blob URL, there would have been a second and worse bug, because blob URLs do
not survive a page reload and restored history would have rendered as broken
images.
Everything else came back clean, all of it the same shape:
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
Revoking on the line after click() looks alarming, but the browser starts the
download synchronously during the click, so the URL is still valid when it
matters. Create, use, release, all inside one function. That pattern is fine and
does not need changing.
The rule of thumb
Two questions catch this class of bug before it ships.
Does any cleanup effect free a resource that appears in its own dependency array? If yes, it is not an unmount handler no matter what the comment says. It fires on every change, and it frees something still in use.
Can you point at the exact line that releases each resource you acquire? If the answer is "the cleanup effect handles it", check which schedule that effect actually runs on. If there is no answer at all, you have a leak.
This is not really a React problem. The same trap exists with
AbortController, WebSocket connections, setInterval handles, and anything
else with a manual lifecycle. React just makes it easy to write a dependency
array that quietly turns "release on unmount" into "release constantly". The
same care applies to anything you allocate in a component that outlives a
render, and the boundary matters more than it used to now that a tree is split
across server and client components:
a resource like this can only ever be held on the client side of that line.
If you want to see the fixed version in action, both the image compressor and the HEIC to JPG converter do all their work in the browser, which means a lot of object URLs and no server to hide a leak behind.
Tools in this post
Related Tool
Image Compressor
Compress, resize, and convert JPEG, PNG, and WebP images directly in your browser. Optimize image file size without losing quality.
Try it freeRelated Tool
HEIC to JPG Converter
Convert iPhone HEIC photos to JPG, PNG or WebP in bulk. Runs on your device, so your photos are never uploaded to anyone.
Try it freeTagged with
Written by
Jamith NimanthaSoftware developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.
Related Articles
All articles →
next/og: Expected div to Have Explicit display: flex, Fixed
Satori throws this error when a div has one child, not just when it has several. Here is the actual rule, the four cases where the message misleads you, and what each workaround costs.
React Server Components in Depth: What They Are and When to Use Them
React Server Components fundamentally change how we think about rendering. This guide breaks down how they work, how they differ from Client Components, and the patterns that will make your Next.js apps faster.
Getting Started with Next.js 16: A Complete Guide
Everything you need to know to build fast, modern web applications with Next.js 16 App Router, Server Components, and TypeScript. From project setup to production deployment.