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).
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
- 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.
- Make creation idempotent. Components re-render; calling
createObjectURLin render without caching creates a new URL — and a new leak — on every render. - 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.
- 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.
- Dispose on unmount. Everything the field created is revoked when the field goes away.
- Write useful
alttext. “Preview of receipt-march.jpg” identifies which file the image represents; an emptyalthides the preview from screen-reader users who may want to confirm they picked the right photo.
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.
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.