Previewing a selected image with FileReader.readAsDataURL copies the whole file into a base64 string a third larger than the original, on the main thread, and keeps it alive as long as any <img> or state references it — a form where users pick twenty 8 MB phone photos can easily hold several hundred megabytes of strings.

URL.createObjectURL(file) is the better tool: it returns a short blob: URL that points at the file without copying it, instantly. Its one cost is ownership — the browser keeps the file’s data alive until you call URL.revokeObjectURL, or the page unloads. In a file upload field where files are added, replaced and removed, every one of those events must revoke exactly the URLs it no longer needs.


Context and prerequisites

The two approaches compared:

  • Data URL (FileReader.readAsDataURL): asynchronous read of the entire file, base64 encoding (+33% size), the string lives in JavaScript memory and can be stored, serialised or sent anywhere. Garbage-collected when unreferenced.
  • Object URL (URL.createObjectURL): synchronous, constant time, no copy. The URL is only valid in the current document. The underlying data is kept alive by the URL registry until revoked — not garbage-collected when your code forgets the string.

That last point is the leak. Removing the <img> from the DOM and dropping the string from state does nothing; only revokeObjectURL releases it. And revoking too early breaks the preview (the image shows as broken if it has not finished decoding, and any later re-render with the same URL fails).

Data URL and object URL for previews A data URL is created by reading and base64-encoding the whole file, uses about one and a third times the file size in string memory, lives as long as any reference, and is freed by the garbage collector. An object URL is created instantly without copying, adds no copy of the data, lives until revokeObjectURL or page unload, and leaks if never revoked or breaks if revoked while still displayed. Aspect Data URL Object URL Create read + encode whole file instant, no copy Memory about 1.33× file size as a string references the original data Freed when unreferenced (GC) revokeObjectURL or page unload Typical bug slow and memory-heavy never revoked, or revoked too early

The core pattern: a preview registry that owns its URLs

/**
 * Owns every object URL a file field creates. One URL per item id; creating
 * a new URL for an id revokes the old one; disposing revokes everything.
 */
export class PreviewRegistry {
  private urls = new Map<string, string>();

  urlFor(itemId: string, file: Blob): string {
    const existing = this.urls.get(itemId);
    if (existing) return existing;               // idempotent: re-renders reuse the URL
    const url = URL.createObjectURL(file);
    this.urls.set(itemId, url);
    return url;
  }

  replace(itemId: string, file: Blob): string {
    this.release(itemId);                         // revoke the previous file's URL first
    return this.urlFor(itemId, file);
  }

  release(itemId: string): void {
    const url = this.urls.get(itemId);
    if (!url) return;
    URL.revokeObjectURL(url);
    this.urls.delete(itemId);
  }

  /** Revoke everything except ids still in use (e.g. after a list change). */
  retainOnly(liveIds: Iterable<string>): void {
    const keep = new Set(liveIds);
    for (const id of [...this.urls.keys()]) if (!keep.has(id)) this.release(id);
  }

  dispose(): void {
    for (const url of this.urls.values()) URL.revokeObjectURL(url);
    this.urls.clear();
  }
}
// React: one registry per field, disposed on unmount.
function FilePreviews({ items }: { items: { id: string; file: File }[] }) {
  const registry = useMemo(() => new PreviewRegistry(), []);
  useEffect(() => () => registry.dispose(), [registry]);
  // After every list change, drop URLs for items that are gone.
  useEffect(() => { registry.retainOnly(items.map((i) => i.id)); }, [items, registry]);
  return (
    <ul>
      {items.filter((i) => i.file.type.startsWith("image/")).map((i) => (
        <li key={i.id}><img src={registry.urlFor(i.id, i.file)} alt={`Preview of ${i.file.name}`} width={96} /></li>
      ))}
    </ul>
  );
}

Step-by-step walkthrough

  1. Create URLs through one owner. A registry keyed by item id guarantees at most one live URL per item and gives you a single place to revoke from.
  2. Make creation idempotent. Components re-render; calling createObjectURL in render without caching creates a new URL — and a new leak — on every render.
  3. Revoke on replace. When a single-file field gets a new file, the old file’s URL is released before the new one is created.
  4. Revoke on remove — unless undo is pending. If removal is undoable, as in undoing row deletion in repeatable groups, keep the URL until the removal expires so a restored item’s preview still works.
  5. Dispose on unmount. Everything the field created is revoked when the field goes away.
  6. Write useful alt text. “Preview of receipt-march.jpg” identifies which file the image represents; an empty alt hides the preview from screen-reader users who may want to confirm they picked the right photo.
Every event that must touch the registry When a file is added, a URL is created for its item id. When the component re-renders, the existing URL is reused rather than creating a new one. When the file in a single-file field is replaced, the old URL is revoked and a new one created. When an item is removed with undo pending, its URL is kept. When the undo window expires, its URL is revoked. When the field unmounts, every remaining URL is revoked. Add createObjectURL for the item id. Instant; no copy of the file. Re-render Reuse the cached URL. Never create in render without the cache. Replace Revoke old, create new. The old file's data is released immediately. Remove / undo expires Keep while undo is possible, then revoke. A restored item's preview must still work. Unmount dispose(): revoke everything. Nothing outlives the field.

Failure modes and edge cases

1. Creating URLs in render

<img src={URL.createObjectURL(file)} /> creates a fresh URL on every render and never revokes any. In a form that re-renders per keystroke, that is a new registry entry per keystroke per image.

2. Revoking in onload

A common “fix” is revoking the URL as soon as the image loads. That works for a one-shot display but breaks when the component re-renders and the <img> needs the URL again, or when the image is shown in two places (thumbnail and lightbox). Revoke on ownership events, not on load.

3. Strict Mode and effects

React Strict Mode runs effect cleanups and re-runs effects in development. A registry created with useMemo survives, and dispose in cleanup followed by urlFor in the next render re-creates URLs lazily — which is why urlFor is called during render rather than in an effect.

4. Large images decoded at full size

A 48-megapixel photo previewed at 96 pixels wide still decodes at full resolution unless you downscale. For grids of many photos, create a thumbnail with createImageBitmap(file, { resizeWidth: 192 }) and draw it to a canvas, or decode it at a smaller size, and preview that instead.

5. Previews for restored drafts

Files restored from IndexedDB come back as File or Blob objects, so previews work the same way. Files restored as server ids (already uploaded) need a server URL for the preview instead; do not try to create object URLs from ids. See storing large drafts in IndexedDB.

Memory held after picking and removing 20 photos Worked arithmetic for twenty photos of 8 megabytes each. Data URL previews still referenced from state hold about 213 megabytes of base64 strings. Object URLs that were never revoked hold the original 160 megabytes of file data until the page unloads. Object URLs revoked on remove hold nothing once the photos are removed. data URLs still referenced 213 MB object URLs never revoked 160 MB object URLs revoked on remove 0 MB Arithmetic, not a measurement: 20 × 8 MB = 160 MB of file data; base64 adds a third, about 213 MB.

Verification checklist


Frequently Asked Questions

Do object URLs leak across page loads?

No. Object URLs are scoped to the document that created them and are all released when it unloads. The leak is within a long-lived page — a single-page app where users stay for an hour adding and removing files.

Can I send an object URL to the server or store it in a draft?

No. A blob: URL is meaningful only inside the document that created it. Store and send the File itself (or the server id after upload), and create a new object URL whenever a preview is needed.

Are object URLs safe to use in img src under a strict CSP?

Only if your Content Security Policy allows blob: in img-src. Add img-src 'self' blob: for previews; you do not need to allow data: if you avoid data URLs.


Related

← File Upload Fields and Binary State