Skip to content
All posts
Tutorial8 min read

Why your React useEffect is revoking its own blob URL

J
Jamith Nimantha
August 24, 2026
On this page

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:

  1. Hold the object URL in a useRef, not in state you also depend on.
  2. Revoke the old URL at the moment you replace it, not in an effect.
  3. 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:

tsx
// 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:

  1. A file is selected. originalUrl becomes blob:A.
  2. The first compression reads blob:A, produces output, sets compressedUrl to blob:B.
  3. That state change makes the dependencies differ, so React runs the previous cleanup. It revokes blob:A.
  4. compressedUrl was also in the useCallback dependency list for the compression function, so that callback was recreated, the debounced effect re-fired, and it ran img.src = blob:A against a URL that no longer exists.
  5. 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.

js
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:

text
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:

text
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:

tsx
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:

tsx
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:

tsx
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 free

Related 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 free
J

Written by

Jamith Nimantha

Software developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.

Share this post

Back to all posts

Related Articles

All articles →