
The Blob URL Is a Memory Leak
Discover why URL.createObjectURL bypasses the garbage collector and how to prevent your SPA from silently accumulating unrevoked memory references.
I remember debugging a client-side image editor a few years back. The user reported that after editing about fifty high-resolution photos, the browser tab would simply vanish—the "Aw, Snap!" of death. I checked the Chrome Task Manager and watched the memory climb like a rocket, even though my React state was clean and my arrays were being emptied.
The culprit was a list of strings. Specifically, strings that looked like blob:https://example.com/b47a12....
In our quest to build snappy, rich-media web apps, we’ve leaned heavily on URL.createObjectURL(). It’s a magic wand. You take a raw File or Blob object, and the browser gives you a URL you can stick into an <img> tag or a <a> download link. But there’s a catch that almost no one talks about: that URL is a permanent, un-garbage-collectable reference to a chunk of memory.
The Invisible Tether
When you call URL.createObjectURL(blob), you aren't just converting data to a string. You are telling the browser engine: "Hold onto this binary data in memory and keep it reachable via this unique URL key."
The browser then adds an entry to an internal mapping—let's call it the BlobURLStore. As long as that mapping exists, the Blob itself cannot be garbage collected. It doesn't matter if you nullify the original Blob variable in your JavaScript code. It doesn't matter if the component that created it is unmounted.
The browser has no way of knowing when you’re "done" with that URL. To the browser, that URL is a resource, much like an image hosted on a server. Since the browser doesn't know if you'll try to fetch that URL again in five minutes or five hours, it plays it safe and keeps the data alive.
The only way that memory gets released is if you explicitly tell the browser to let go, or if you close the document entirely.
The SPA Trap
In the era of jQuery and Multi-Page Applications (MPAs), this wasn't really a problem. You’d upload a file, create a preview URL, and eventually, the user would click "Submit" or navigate to a different page. When the page unloaded, the entire memory space was wiped clean. The "leak" lived for maybe two minutes.
But Single Page Applications (SPAs) are different. A user might keep a Dashboard or an Editor tab open for eight hours. If your SPA allows users to preview images, generate PDFs, or handle CSV exports, and you aren't manually revoking those URLs, you are leaking memory every single time the user performs an action.
// This looks innocent in a React component
function ImagePreview({ file }) {
const url = URL.createObjectURL(file); // Every re-render creates a NEW leak
return <img src={url} alt="Preview" />;
}In the example above, every time ImagePreview renders, a new Blob URL is created. If the component re-renders ten times, you have ten copies of that file sitting in RAM. Even if the component unmounts, those ten files stay in the browser's memory.
Why the Garbage Collector is Blind
You might wonder why the Garbage Collector (GC) can't just see that the string isn't being used anymore.
The problem is the nature of the string itself. URL.createObjectURL returns a standard DOMString. To the GC, that string is just a primitive value. It has no special "link" back to the memory it represents in the way a Pointer might in C++.
let blob = new Blob([new Uint8Array(10 * 1024 * 1024)]); // 10MB
let url = URL.createObjectURL(blob);
blob = null; // The Blob is gone from your scope...
// ...but the 10MB is still in RAM because 'url' still exists in the browser's internal map.
url = null; // Now even the string is gone from your scope.
// The browser's internal map STILL has the 10MB because it never heard a 'revoke' command.The browser engine (Blink or WebKit) maintains a mapping that is outside the reach of the V8/JavaScript heap. The JS engine manages your objects; the Browser engine manages the Blobs. They don't always talk to each other about when a string is "finished."
The Ritual of Revocation
The fix is technically simple: URL.revokeObjectURL(url). But the implementation is where it gets tricky, especially when dealing with the lifecycle of modern frameworks.
In vanilla JavaScript, you should revoke the URL as soon as it's no longer needed. If you're just showing a preview for a split second to grab some metadata, do it immediately.
const img = new Image();
const url = URL.createObjectURL(myBlob);
img.onload = () => {
// Once the image is loaded into the internal cache of the <img> element,
// the Blob URL is often no longer needed.
URL.revokeObjectURL(url);
console.log("Memory released!");
};
img.src = url;In React, the useEffect hook is your best friend for managing this lifecycle. It ensures that when the component unmounts (or the file changes), the old URL is cleaned up.
import { useState, useEffect } from 'react';
function FilePreview({ file }) {
const [preview, setPreview] = useState(null);
useEffect(() => {
if (!file) return;
// Create the URL
const url = URL.createObjectURL(file);
setPreview(url);
// This is the "cleanup" function
return () => {
URL.revokeObjectURL(url);
console.log("Revoked:", url);
};
}, [file]); // Only re-run if the file changes
if (!preview) return null;
return <img src={preview} />;
}The "Loaded" Race Condition
There is a subtle "gotcha" when revoking URLs. If you revoke a URL *immediately* after setting it to an src attribute, the browser might not have finished reading the blob yet.
const url = URL.createObjectURL(blob);
myImage.src = url;
URL.revokeObjectURL(url); // CRASH! The browser might not have started loading it yet.If you revoke too fast, the image might fail to load with a "Not Found" error. You need to wait for the load event or give the browser enough of a tick to start the fetch process. In most cases, revoking in a cleanup function (like the React example above) is safe because the image has likely already rendered.
Detecting the Leak
How do you know if you're leaking Blob URLs? Standard Heap Snapshots in Chrome DevTools won't always show the full picture because, as we discussed, the data lives in the "Internal" memory of the browser, not the JS Heap.
1. Chrome Task Manager: Press Shift + Esc in Chrome. Watch the "Memory Footprint" column. If it grows every time you interact with a file and never goes down, you have a leak.
2. `chrome://blob-internals/`: This is a hidden gem. Type this into your address bar. It shows you every single Blob currently registered in the browser engine, how big it is, and its UUID. If you see a list of 50 blobs from your localhost after navigating away from a page, you’ve found your leak.
Is there a better way?
Sometimes, you can avoid createObjectURL entirely.
1. Data URLs (Base64)
For very small files (like icons or tiny thumbnails), you can use a FileReader to create a data:image/png;base64,... string.
* Pros: Garbage collected like any other string.
* Cons: 33% larger than the original binary data, and very slow for large files (encoding/decoding 5MB into Base64 will freeze your main thread).
2. Use the Blob Directly (In some APIs)
Modern browser APIs are getting better at handling Blobs directly without needing a URL middleman. For example, if you're drawing an image to a canvas, you can use createImageBitmap(blob).
const bitmap = await createImageBitmap(myBlob);
context.drawImage(bitmap, 0, 0);
// Then later...
bitmap.close(); // Don't forget to close bitmaps too!3. The `srcObject` property
While mostly used for MediaStreams (camera/video), there have been proposals and movements toward using srcObject for Blobs, though support is not universal for all tags.
Conclusion
URL.createObjectURL is a powerful tool, but it's one of the few places in modern JavaScript where you are forced to manage memory manually. It breaks the "contract" of the garbage collector.
If you're building an SPA, do an audit. Search your codebase for createObjectURL. For every instance you find, ensure there is a corresponding revokeObjectURL called during a cleanup phase. Your users' RAM—and their battery life—will thank you.
Don't let your application be the reason someone has to restart their browser. It's a silent failure, and those are the hardest ones to fix.


