A user selects 40 photos, removes half of them, adds another batch, and leaves the upload page open. The thumbnails disappear when expected. The tab’s memory usage keeps climbing.
The upload endpoint may never receive a request. This problem can happen entirely inside the browser.
Local previews often start with one line:
preview.src = URL.createObjectURL(file);
That line creates a resource with a lifetime. Removing the preview element does not automatically end that lifetime.
For a long-lived application, image previews need explicit ownership and cleanup.
A blob URL is a resource handle
URL.createObjectURL() creates a URL that refers to a Blob, including a File selected through an input.
It does not upload the file or produce a permanent address. It gives browser APIs a way to consume the underlying data.
Every call creates a new URL, even for the same file:
const first = URL.createObjectURL(file);
const second = URL.createObjectURL(file);
console.log(first === second); // false
Both URLs need their own cleanup.
The browser normally releases object URLs when the document unloads. That is insufficient for a page that remains open while users process hundreds of files.
The useful rule is simple: the component that creates an object URL owns its revocation.
Make the preview own its resources
Here is a single-image preview controller. It handles replacement, failed decoding, removal, and disposal.
The HTML needs a file input, a preview container, and a status element:
<input id="file" type="file" accept="image/*">
<div id="preview"></div>
<p id="status" role="status"></p>
The controller keeps one active record:
function createPreviewController(container, status) {
let current = null;
let disposed = false;
function releaseCurrent() {
const previous = current;
current = null;
container.replaceChildren();
if (previous) {
previous.image.removeAttribute("src");
URL.revokeObjectURL(previous.url);
}
}
async function show(file) {
if (disposed) return;
releaseCurrent();
status.textContent = "";
if (!file) return;
const record = {
url: URL.createObjectURL(file),
image: new Image(),
};
current = record;
record.image.alt = "Preview of the selected image";
record.image.style.maxWidth = "100%";
record.image.style.height = "auto";
record.image.src = record.url;
status.textContent = "Preparing preview…";
try {
await record.image.decode();
// Selection may have changed while decoding.
if (disposed || current !== record) return;
container.replaceChildren(record.image);
status.textContent = "";
} catch {
// A replaced preview must not clear a newer one.
if (disposed || current !== record) return;
releaseCurrent();
status.textContent = "This image could not be previewed.";
}
}
function dispose() {
if (disposed) return;
disposed = true;
releaseCurrent();
status.textContent = "";
}
return { show, dispose };
}
Connect it to the input:
const input = document.querySelector("#file");
const controller = createPreviewController(
document.querySelector("#preview"),
document.querySelector("#status"),
);
function onChange() {
void controller.show(input.files?.[0]);
}
input.addEventListener("change", onChange);
// Call this when the upload view is removed.
function destroyUploadView() {
input.removeEventListener("change", onChange);
controller.dispose();
}
In a framework, destroyUploadView() belongs in the component’s teardown path.
Cleanup alone does not prevent stale results
The identity check after decode() is essential.
Imagine this sequence:
- The user selects image A.
- Decoding A starts.
- The user selects image B.
- Decoding B starts.
- A’s asynchronous operation settles.
Without the identity check, A could replace B or display an error belonging to an obsolete selection.
Each operation captures its own record. It may update the interface only while that record remains current.
Revoking a URL is not a general-purpose cancellation mechanism for every operation already consuming it. Resource cleanup and stale-result protection solve different problems. This controller needs both.
Do not revoke immediately after assigning src
This is a race:
const url = URL.createObjectURL(file);
image.src = url;
URL.revokeObjectURL(url);
The browser may still need to resolve the URL.
Revoking after loading is more deliberate, but it can also interfere with later interactions that depend on the URL, such as opening the image separately.
A straightforward policy is to retain the URL while its preview remains available. Revoke it when the preview is replaced, removed, or disposed.
That makes the lifecycle predictable and easier to review.
Small thumbnails can still require large decodes
Fixing object URL leaks does not establish a memory budget.
A compressed image might occupy a few megabytes on disk while its decoded pixels require much more space. An 8,000 × 6,000 image represented as four bytes per pixel is approximately 183 MiB for one pixel buffer.
Actual browser memory usage varies, and additional buffers may exist.
Setting an image to width: 160px changes its displayed size. It does not guarantee that decoding will use only a thumbnail-sized buffer.
For a multi-file uploader, also limit how many previews are prepared concurrently. Render previews near the viewport, release those no longer needed, and avoid decoding an entire selection at once.
Generating smaller preview blobs can reduce retained preview costs. However, generating those thumbnails may itself require decoding the original. Measure that temporary peak too.
File validation is a separate boundary
The input’s accept attribute is a selection hint. It is not security validation.
Likewise, file.type can be missing or misleading. A successful browser preview does not prove that a file satisfies the application’s upload policy.
Apply independent server-side checks for supported formats, file size, and processing limits.
Never insert uploaded SVG markup directly into the page to create a preview. Treat user-provided content as untrusted, even when its filename ends in an image extension.
Test the lifecycle, not just the first preview
A useful manual test repeats the uncomfortable paths:
- Replace a large image before its preview appears.
- Remove a file while decoding is pending.
- Select an unsupported or damaged image.
- Leave the upload view while a preview is being prepared.
- Repeat selection and removal dozens of times.
For automated tests, spy on object URL creation and revocation. After disposal, every created URL should have a matching revocation.
Do not expect the browser’s process memory to fall immediately after cleanup. Garbage collection, decoder caches, and allocation strategies affect what memory tools display.
The actionable failure is resources accumulating across repeated interactions. Explicit ownership makes those leaks easier to locate—and prevents an apparently harmless preview feature from consuming the whole session.
Top comments (0)