Previewing Files with Object URLs
Call URL.createObjectURL(file) to get a blob: URL that points at the file’s bytes without reading them into JavaScript, assign it to an <img>, <video>, <audio> or <iframe>, and call URL.revokeObjectURL(url) as soon as the element no longer needs it — tie creation and revocation together in one small helper so no path through your UI forgets the second half.
A preview is the user’s chance to catch the wrong file before it uploads: the blurry photo, the draft instead of the final PDF, the video with no sound. Previews built with FileReader.readAsDataURL read the whole file into a base64 string first, which is slow and memory-hungry for anything larger than a thumbnail. Object URLs are instant and cost almost nothing — until you forget to revoke them, at which point every preview you ever showed stays in memory for the life of the page. This page is part of file API and Blob objects in upload fundamentals and browser APIs. The data-URL alternative and its costs are covered in converting data URLs to Blobs before upload.
When to use this approach
- Users select or drop files and you want a preview before (or during) upload.
- Files can be large — videos, multi-page PDFs, high-resolution photos — where reading them into memory for a preview would be wasteful.
- The page stays open for a long session (an upload manager, an editor), so leaks accumulate if URLs are not revoked.
Prerequisites
- Any current browser —
URL.createObjectURLandrevokeObjectURLare universally supported. - A Content Security Policy that allows
blob:in the relevant directives (img-src,media-src,frame-src) if you use one. - Knowledge of which file types the browser can render natively: most images, MP4/WebM video, MP3/AAC/Opus audio, PDF in an
<iframe>on desktop.
What an object URL actually is
Implementation
type PreviewKind = "image" | "video" | "audio" | "pdf" | "none";
export function previewKind(file: File): PreviewKind {
const t = file.type;
if (t.startsWith("image/") && t !== "image/heic" && t !== "image/heif") return "image";
if (t === "video/mp4" || t === "video/webm" || t === "video/quicktime") return "video";
if (t.startsWith("audio/")) return "audio";
if (t === "application/pdf") return "pdf";
return "none";
}
/** Owns one object URL at a time and guarantees it is revoked. */
export class PreviewSlot {
private url: string | null = null;
constructor(private readonly container: HTMLElement) {}
show(file: File): void {
this.clear();
const kind = previewKind(file);
if (kind === "none") {
this.container.textContent = `${file.name} — ${(file.size / 1048576).toFixed(1)} MB (no preview)`;
return;
}
this.url = URL.createObjectURL(file);
let el: HTMLElement;
switch (kind) {
case "image": {
const img = document.createElement("img");
img.alt = `Preview of ${file.name}`;
img.decoding = "async";
img.src = this.url;
el = img;
break;
}
case "video": {
const v = document.createElement("video");
v.controls = true; v.muted = true; v.playsInline = true; v.preload = "metadata";
v.src = this.url;
v.addEventListener("error", () => this.fallback(file), { once: true }); // e.g. HEVC on Chrome
el = v;
break;
}
case "audio": {
const a = document.createElement("audio");
a.controls = true; a.preload = "metadata";
a.src = this.url;
el = a;
break;
}
case "pdf": {
const f = document.createElement("iframe");
f.title = `Preview of ${file.name}`;
f.src = `${this.url}#toolbar=0&view=FitH`;
el = f;
break;
}
}
this.container.replaceChildren(el);
}
private fallback(file: File): void {
this.clear();
this.container.textContent = `${file.name} will be uploaded, but this browser cannot preview it.`;
}
clear(): void {
if (this.url) { URL.revokeObjectURL(this.url); this.url = null; }
this.container.replaceChildren();
}
}
// Usage: one slot per file row; clear() when the row is removed or the upload completes.
const slot = new PreviewSlot(document.querySelector("#preview")!);
document.querySelector<HTMLInputElement>("#file")!.addEventListener("change", (e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) slot.show(f);
});
window.addEventListener("pagehide", () => slot.clear());
Line-by-line on the parts that matter
- One URL per slot, revoked before the next.
show()callsclear()first, so replacing a preview never leaves the previous URL registered. This is the whole trick: make it impossible to create without a matching revoke. - Revoke after the element is detached, not immediately after assignment. For images, revoking right after
onloadworks; for video and audio it does not, because media elements read the URL repeatedly as the user seeks. Keeping the URL for the element’s lifetime and revoking on removal is correct for every type. previewKindfromfile.type. Here the MIME type is only a guess at what the browser can render, which is harmless — a wrong guess falls back to text. For security decisions, never trust it; see why browser MIME types are unreliable.- HEIC excluded from image previews. Chrome and Firefox cannot render HEIC, and a broken-image icon looks like a bug. Convert first, as in converting HEIC images to JPEG in the browser, then preview the result.
- Video
errorfallback. iPhone videos are often HEVC in a.mov, which Chrome on Windows cannot play. The element’serrorevent is the only reliable signal; turn it into a text preview rather than a black box. preload="metadata". Loads only enough to show duration and the first frame, not the whole video.
How previews leak without revocation
Previews in frameworks
Component frameworks make the create-and-revoke pairing natural, if you put both halves in the same lifecycle hook. In React, create the URL inside useEffect keyed on the file and return a cleanup that revokes it; do not create it during render, where it runs on every re-render and the cleanup has nothing to hold. In Vue, create in watch on the file and revoke in the same watcher’s onCleanup and in onUnmounted. In any framework, the rule is the same as in PreviewSlot: the code that creates the URL is the code that revokes it.
import { useEffect, useState } from "react";
export function useObjectUrl(file: File | null): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) { setUrl(null); return; }
const u = URL.createObjectURL(file);
setUrl(u);
return () => URL.revokeObjectURL(u); // runs when file changes or the component unmounts
}, [file]);
return url;
}
For large galleries of previews — dozens of thumbnails in an upload queue — decode small thumbnails once with createImageBitmap(file, { resizeWidth: 160 }) and draw them to canvases, rather than keeping dozens of full-resolution images alive through object URLs.
Previews that are safe to show
A preview renders user-supplied content inside your page, so treat it with the same care as any other untrusted input. Images, audio and video decoded by the browser’s media stack are sandboxed by design and cannot run script. Two types need more thought.
SVG. An SVG shown through <img src="blob:…"> is rendered as an image: scripts inside it do not run and it cannot touch your page. The same SVG loaded in an <iframe> or inserted into the DOM as markup is a document that can execute script in your origin. Always preview SVG through <img>, never as inline markup, and sanitise it on the server before anyone else sees it — the server-side rules are in sanitizing SVG uploads against XSS.
HTML and PDFs in iframes. A blob URL inherits the origin of the document that created it. An HTML file opened in an iframe from a blob URL runs with your origin’s privileges; a PDF is rendered by the browser’s viewer, which is safe, but a mislabelled HTML file with a .pdf name and an application/pdf type guess is not. Only preview PDFs whose first bytes are %PDF-, and add sandbox to any iframe that shows user content, which strips script execution and same-origin access even if the file is not what it claims to be.
Everything else — documents, archives, unknown binaries — gets a text summary with name, size and detected type. It is less exciting than a rendered preview and has no attack surface at all.
Configuration gotchas
Refused to load the image 'blob:…' because it violates the following Content Security Policy directive: "img-src 'self'". Add blob: to img-src (and media-src, frame-src for video and PDF). It does not weaken the policy meaningfully: blob URLs can only be created by your own scripts.
PDF preview is blank on iOS and Android. Mobile browsers do not render PDFs in iframes; they offer a download instead. Show the file name, size and page count (from a PDF library if you need it) on mobile, and keep the iframe preview for desktop.
The preview works, then breaks after upload completes. Code that revokes the URL on upload completion while the preview is still displayed turns the image into a broken icon on the next repaint or seek. Revoke when the element is removed, not when the upload ends.
net::ERR_FILE_NOT_FOUND for a blob URL. The URL was created in another document (an iframe, a closed popup) or has already been revoked. Blob URLs are scoped to the document that created them.
Previews for each file type
Verification
In Chrome DevTools, use the Memory panel to confirm previews are released:
- Take a heap snapshot, preview twenty large photos one after another, then take another snapshot.
- Filter by “Blob”: with revocation, only one or two blobs are retained; without it, all twenty remain.
And a quick console check that URLs are really revoked:
const f = new File([new Uint8Array(1024)], "x.bin");
const u = URL.createObjectURL(f);
URL.revokeObjectURL(u);
const ok = await fetch(u).then(() => "still alive", () => "revoked");
console.log(ok); // "revoked"
Frequently Asked Questions
Is readAsDataURL ever better?
Only when you need the preview as a string that survives the document — storing a small thumbnail in localStorage, or sending it in JSON. For on-page previews, object URLs are faster and use a fraction of the memory.
Can I upload using the blob URL?
You can fetch(blobUrl) to get the bytes back, but there is no reason to: pass the original File to fetch or FormData directly. Blob URLs are for rendering, not transport.
Do blob URLs work across tabs?
No. They are valid only in the document that created them. To share a file with another tab or a worker, post the File object itself; it is structured-cloneable.