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

  1. Any current browser — URL.createObjectURL and revokeObjectURL are universally supported.
  2. A Content Security Policy that allows blob: in the relevant directives (img-src, media-src, frame-src) if you use one.
  3. 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

Object URL registry mapping blob URLs to files createObjectURL adds an entry to the document's blob URL registry mapping a blob URL string to the File, which keeps the file's data alive. The img element fetches the blob URL and the browser reads bytes straight from the file. revokeObjectURL removes the entry, allowing the data to be released once nothing else references it. A string that keeps a file alive File (4.2 MB) bytes on disk blob URL registry blob:https://app/5e1b… → File (strong reference) <img src=blob:…> decodes on demand revokeObjectURL(url) entry removed; data can be freed Garbage collection cannot see that a string in the DOM refers to the entry — only revoking releases it.
The registry entry, not the element, is what keeps the data alive, and only revocation removes it.

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() calls clear() 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 onload works; 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.
  • previewKind from file.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 error fallback. iPhone videos are often HEVC in a .mov, which Chrome on Windows cannot play. The element’s error event 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

Memory held across a session of 50 previews In a session previewing fifty 6 megabyte photos one after another, memory without revocation climbs steadily to about 300 megabytes of retained file data and decoded images. With revocation on replace, memory stays flat at about the size of one or two previews. 50 photos previewed in turn (6 MB each) 300 MB 0 never revoked revoked on replace 1 50 On a phone, the red line ends in a reloaded tab long before photo 50.
Each unrevoked URL pins a file (and often its decoded image) for the lifetime of the document.

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

Preview element and fallback per file type Images use img, except HEIC which needs conversion first. MP4 and WebM video use video, with a text fallback on decode error such as HEVC. Audio uses audio. PDF uses iframe on desktop and a text summary on mobile. Everything else shows name and size. One URL, the right element, a graceful fallback image <img> HEIC: convert first video <video> HEVC: text on error event audio <audio> most formats just play PDF <iframe> mobile: name, size, pages other text only name + size Every branch that creates a URL goes through the same slot, so every branch revokes it.
A single owner for the URL keeps the revocation rule simple no matter how many element types you support.

Verification

In Chrome DevTools, use the Memory panel to confirm previews are released:

  1. Take a heap snapshot, preview twenty large photos one after another, then take another snapshot.
  2. 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.