Skip to content
Chapter 16Lesson 3

Blob, File, and object URLs: the upload primitives

The browser's byte primitives, Blob, File, and object URLs, that turn a picked file into a preview before it uploads.

You’re building the account settings page, and it needs a profile photo field. The shape is the one every SaaS app shares: an <input type="file" accept="image/*">, and the moment the user picks an image you want a thumbnail preview to appear with the filename beside it, so they can confirm they grabbed the right file before they save. On save, the bytes upload. You build a version of this constantly, in avatar pickers, attachment previews, logo uploads, and CSV imports, so it’s worth getting the primitives under it right once.

So you write the obvious thing. The change handler hands you a file, you drop it into an <img src={pickedFile}>, and you get a broken-image icon, because a file object isn’t a URL and the browser has no idea what to do with it. You search for the fix, and the top answer reaches for FileReader, which works but carries a pile of event-handler boilerplate from an older era of the platform. The call that does render the preview cleanly, URL.createObjectURL, leaks a chunk of memory on every re-pick unless you add one more step that nobody’s first draft includes. So this lesson answers two questions: which platform primitives turn picked bytes into a preview, and what is the one cleanup step that keeps the obvious version from leaking?

By the end you’ll have a leak-free pick-to-preview island: pick a file, see the thumbnail, see the name, with no memory left behind. You’ll also have a clear model of where those bytes go next when it’s time to upload them. Three nouns build the whole thing in a chain, so we’ll take them one at a time and assemble them at the end.

Start at the bottom of the chain. A Blob is the platform’s container for a chunk of binary data: an immutable, fixed-length sequence of bytes. It doesn’t know or care whether those bytes are an image, a CSV, or a zip. It’s just the bytes, plus a label saying what kind of thing they’re supposed to be. You read two properties off it: size, the byte length as a number, and type, the MIME type string.

The word claimed in that definition matters: type is whatever the producer said the bytes are, not what they actually are. Nothing verifies it, so a Blob labelled 'image/png' can hold anything at all. That detail comes back the moment a user is involved.

You construct one with new Blob(parts, { type }), where parts is an array of pieces to concatenate: strings, ArrayBuffers, typed arrays like Uint8Array (the byte array from the streaming chapter), or other Blobs. The browser stitches them together into one contiguous byte sequence.

const csv = new Blob(['id,name\n1,Ada\n'], { type: 'text/csv' });

When do you reach for new Blob(...)? Whenever your code is the one minting the bytes: assembling an upload payload in memory, slicing a piece out of a larger file, or building generated content like a CSV export before you hand it to a download. You’ve already met Blobs without naming them as a category. The rich ClipboardItem content from the previous lesson held Blobs, Response.blob() from the fetch chapter hands one back, and a fetch upload body accepts one directly. So the framing to keep is that Blob is the universal in-memory binary container the platform passes around. Once bytes are in a Blob, every byte-shaped API on the platform speaks the same language.

There is one thing to watch for at construction time. If you write new Blob(['hello']) with no options object, type comes out as the empty string '': not a sensible default, just unset. Most consumers treat an empty type as application/octet-stream, the generic “some bytes, who knows” type. So when anything downstream is going to branch on the content type, such as a server deciding how to store it or an <img> deciding whether it can render it, pass an explicit type rather than letting it default.

The second noun is a small step up from the first. A File is a Blob: it’s a subclass, so everything you just learned carries over. A File has size, has type, and has the same byte-reading methods. It adds exactly two read-only properties on top: name, the filename as the operating system reports it, and lastModified, a timestamp in milliseconds of when the file was last changed on disk. That’s the whole difference. A File is a Blob that remembers where it came from.

One fact reorganizes how you think about it: your code never constructs a File. The browser does. You don’t new File(...), you receive File objects, and in a SaaS app they arrive from essentially one place. When the user picks files through an <input type="file">, the input’s change event populates event.target.files, a FileList holding everything they selected. (Drag-and-drop is the other source: it fills event.dataTransfer.files with Files the same way. A plain file input covers the upload surface this course builds, so we’ll name drop once and move on.)

Reading the picked file off that event is one line, with one subtlety in it. For a single-file picker you reach for event.target.files?.[0], the first file in the list. The optional chain matters because if the user opens the picker and cancels, there’s no selection and you’d be indexing into nothing. For a multi-file picker you iterate the list, and this is where the first common mistake waits. A FileList is array-like: it has a length and you can index it with files[0], but it is not an array. It has no .map, no .filter, and no .forEach, which are the methods you’d reach for to render a list of thumbnails. Spread it into a real array first, [...files], and then it behaves the way you expect.

const [file, setFile] = useState<File | null>(null);
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>;

accept filters what the OS file picker offers the user. It’s a hint to the picker UI, nothing more, and it does not validate anything (the next note explains why).

const [file, setFile] = useState<File | null>(null);
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>;

event.target.files is the FileList the change event populates, and a single-file pick reads the first entry with ?.[0]. The optional chain matters because a cancelled picker leaves no selection, so it guards against indexing into nothing, and ?? null then falls back cleanly.

const [file, setFile] = useState<File | null>(null);
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>;

setFile(...) stashes the picked File in state; from here the rest of the lesson turns it into a preview. For a multiple picker you’d read the whole FileList instead, and because it’s array-like but not an array, you spread it, [...event.target.files], before you can .map it into a row of thumbnails.

1 / 1

That accept attribute earns its own warning, because it is the thing juniors most often mistake for security. accept="image/png,image/jpeg,image/webp" tells the OS picker to gray out everything that isn’t one of those types. That’s a nicer experience, since the user doesn’t see files they can’t pick, but it is UX, not validation. A determined user can rename a file, drop something past the picker, or hit your endpoint directly with whatever bytes they like. Real content-type and magic-byte validation is a separate job that lives on the server. The upload chapters later in the course own the step where the server reads the actual leading bytes of the file and confirms they match the claimed type before trusting it. This is why the type on a Blob was only ever a claim.

That gives us the single rule that organizes this whole lesson: bytes that come from the user arrive as a File; bytes your own code mints are a Blob. It’s the same byte container underneath, traveling in two directions. The next noun renders both of them the exact same way.

URL.createObjectURL: a renderable handle for bytes

Section titled “URL.createObjectURL: a renderable handle for bytes”

You have a File (or a Blob) in hand. You can’t put it in an <img src> directly, because src wants a URL string and a file object is not a string. URL.createObjectURL is the bridge. You pass it a Blob or a File, and it hands back a string that looks like this:

const previewUrl = URL.createObjectURL(file);
// 'blob:https://app.example.com/9b2c…-uuid'
<img src={previewUrl} alt="" />;

That string is a real URL as far as the DOM is concerned. Any element that takes a URL can consume it: <img src>, <video src>, an <a href download> for a download link, or an <iframe src> for an in-page preview. The element renders the bytes as if they’d been fetched from a server, except nothing was fetched and there’s no server.

Here is the mechanism, and the rest of the lesson follows from it. When you call createObjectURL, the browser adds an entry to an in-memory map: this URL string points to those bytes, held in memory. The string itself is cheap, a few dozen characters, a UUID with a prefix. But the bytes behind it stay alive, pinned in memory, for as long as that map entry exists. Creating the URL is what pins them. They don’t get released when you stop using the string, when the <img> finishes rendering, or when the variable goes out of scope. The map entry is the thing keeping them alive, and it sticks around until you remove it or the page unloads. Keep that in mind, because the leak we’re about to meet follows directly from it.

You might wonder why this is the move rather than the FileReader the internet pushed you toward. FileReader produces a data URL : a data:image/png;base64,… string with the entire file encoded inline. For a two-megabyte photo, that’s a two-and-a-half-megabyte string, since base64 inflates the bytes by about a third, allocated in memory and re-materialized every time something reads it. An object URL makes the opposite trade: a tiny constant-size handle no matter how big the file, with the real bytes referenced once in the browser’s map rather than copied into a string. For previewing a picked file, createObjectURL is the 2026 default. FileReader is the event-driven legacy path, and the one place it still earns its weight is when you specifically need its progress events for a long read. Know that it exists, but reach for the object URL otherwise.

One more point corrects a reflex this chapter has been building. The last two lessons gated their APIs behind a secure context : crypto.subtle, randomUUID, and the clipboard write all silently fail on plain http://. Blob, File, and createObjectURL are not in that group. They work fine on plain HTTP, with no secure context required. What constrains them is different: they’re browser-only. There is no URL.createObjectURL and no <img> on the server, so this code cannot run in a Server Component. It has to live in a client island, behind the same 'use client' boundary you put the Copy button behind. The boundary is the same as the clipboard’s, but the reason differs: not because the API is gated, but because the API simply doesn’t exist outside the browser.

revokeObjectURL and where the cleanup belongs

Section titled “revokeObjectURL and where the cleanup belongs”

Now the central point. You’ve created a handle that pins bytes in memory, and the mirror call releases them. URL.revokeObjectURL(url) takes a URL you created and deletes its entry from that map, which finally lets the bytes be garbage-collected. This is the cleanup reflex the whole chapter has been drilling, and it has a clean one-line form: every createObjectURL has a matching revokeObjectURL. It’s the same shape as aborting a request you started in the DOM chapter and clearing the setTimeout you started in the last lesson: every resource you open in the browser, you close.

Skip the revoke and here is what happens. Each time the user re-picks a photo, your handler calls createObjectURL again and mints a fresh handle for the new file. The <img> swaps to the new URL and renders the new preview, which looks perfect. But the previous URL’s map entry is still there, so the previous file’s bytes are still pinned, with nothing pointing at them and no way to ever release them. In a one-shot avatar picker that’s one stale image’s worth of memory leaked, annoying but survivable. In a gallery or a multi-file attachment list, it scales linearly: every re-pick, and every re-render that re-creates a URL, strands another file’s bytes, and the tab’s memory climbs until something falls over. This is the bug the lesson is here to prevent.

To make the invisible map visible, scrub through the lifecycle below. Watch the memory column on the left fill with pinned byte-blocks and the live handle on the right point at one of them. The leak is the moment a block has no arrow pointing at it but is still sitting in memory.

browser memory object URL <img>
photo-A.png bytes · pinned
blob:…/A
preview renders

createObjectURL(fileA) adds a map entry, and that entry is what pins A’s bytes in memory. The <img> reads the handle through to the bytes, and the preview renders.

browser memory object URL <img>
photo-A.png pinned · stranded
leaked — no consumer
photo-B.png bytes · pinned
blob:…/A
blob:…/B
preview renders

The re-pick mints blob:…/B and the <img> swaps to it, but nothing removed A’s entry, so A’s bytes stay pinned with no consumer. This is the leak, and it repeats on every re-pick.

browser memory object URL <img>
A collected
photo-B.png bytes · pinned
blob:…/B
preview renders

The matching revokeObjectURL(urlA) deletes A’s entry before B renders, so A’s bytes are collected. Only the current preview stays in memory.

browser memory object URL <img>
(never pinned)
blob:…/A
broken image

Revoke synchronously right after setting <img src> and the browser hasn’t read the bytes yet, so the preview is broken. The revoke belongs on unmount or when the URL changes, not the instant the element mounts.

So where does the revoke actually go? There are two correct homes, chosen by context.

For the pick-to-preview flow, it belongs in a cleanup that fires when the file changes or the component unmounts, so swapping to a new photo revokes the old URL and leaving the page revokes the last one. In React that’s the cleanup return of a useEffect keyed to the file. For a one-shot generated download (mint a CSV, link it, let the user click), the revoke goes right after the consumer is done with it, in the link’s click handler. The principle under both is the same: revoke once nothing needs the bytes anymore.

There is a second, opposite mistake that matters just as much: do not revoke too early. It is tempting to write createObjectURL and revokeObjectURL on adjacent lines: create the handle, set <img src>, revoke. That blanks the preview. Setting src doesn’t synchronously read the bytes; the browser reads them a beat later, asynchronously, and if you’ve already revoked the URL by then, the map entry is gone and the <img> renders the broken-image icon. This is the mechanism behind the second-most-common version of this bug, the one that goes “I added the cleanup like the docs said and now my preview is blank.” The revoke runs on unmount or on URL change, after the element has had its chance to read the bytes, never on the line right after you created it.

A few lifecycle facts are worth stating once and then trusting. A blob: URL is origin-scoped: it only works on the page that created it, you can’t share one across tabs, and it doesn’t survive a page reload. The browser revokes all of a page’s object URLs automatically when the page unloads, so your manual revokeObjectURL is about managing memory during the tab’s lifetime, not about cleaning up after the user closes the tab. And setting <img src> to a URL you’ve already revoked renders the broken-image icon, the same failure as revoking too early.

Now you can assemble the whole thing. This is the leak-free pick-to-preview island with every piece you’ve met in one place: the picked File in state, the object URL derived from it, the preview, and the cleanup that keeps it from leaking.

'use client';
export const AvatarPicker = () => {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewUrl != null && <img src={previewUrl} alt="" />}
{file != null && <p>{file.name}</p>}
</div>
);
};

The browser-only island boundary. The <input>, createObjectURL, and <img> all need the DOM, so this can’t be a Server Component. It’s the same boundary as the Copy button, but for a different reason: there’s no secure-context gate here as there is with the clipboard. This is a client island because the API is browser-only, not because it’s gated.

'use client';
export const AvatarPicker = () => {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewUrl != null && <img src={previewUrl} alt="" />}
{file != null && <p>{file.name}</p>}
</div>
);
};

The two state slots: the picked File, and the preview URL string derived from it. They’re separate pieces of state because they change at different moments: the file on pick, the URL as a consequence.

'use client';
export const AvatarPicker = () => {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewUrl != null && <img src={previewUrl} alt="" />}
{file != null && <p>{file.name}</p>}
</div>
);
};

Inside the effect, const url = URL.createObjectURL(file) mints the handle once a file is present, and the if (!file) return guards the no-selection case.

'use client';
export const AvatarPicker = () => {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewUrl != null && <img src={previewUrl} alt="" />}
{file != null && <p>{file.name}</p>}
</div>
);
};

The cleanup return () => URL.revokeObjectURL(url) is the central move of the lesson. It fires when file changes, releasing the old URL before the new preview renders, and again on unmount. It does not fire synchronously after the <img> mounts, which is why the preview survives instead of going blank.

'use client';
export const AvatarPicker = () => {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewUrl != null && <img src={previewUrl} alt="" />}
{file != null && <p>{file.name}</p>}
</div>
);
};

The render. accept is UX-only, event.target.files?.[0] ?? null handles the cancelled-picker case, and the != null guards render the <img> only once a URL exists and the <p> only once a file is picked. Those are null checks rather than bare-truthy tests, with file.name shown beside the preview.

1 / 1

A note on what’s deliberately missing from that block, so nobody “fixes” it later. The markup is bare: a plain <input>, a plain <img>, and a plain <p>, with no design-system components and no styling. That’s on purpose, to keep your attention on the byte primitives instead of the chrome. In a real product you’d wire this to your design system and, in the upload chapters ahead, to a server action that ships the bytes. The hooks are shown as shape, not taught. useState holds the file and the URL; useEffect synchronizes with the object-URL map and cleans it up, which is the textbook use of an effect: syncing with an external system and tearing it down. The mechanics of those hooks land properly later in the course; here, just read the shape.

The pick-to-preview island is the case you’ll build most, but two relatives are worth recognizing so you spot them when they show up.

Downloading a generated file is the mirror image. Sometimes the app mints the bytes itself, such as a CSV export or a generated PDF or JSON, and wants to hand them to the user as a download. This is the Blob direction of the rule from earlier: bytes your code made, not bytes the user picked. The shape is the same createObjectURL, just pointed the other way: build a Blob, make a handle, put it on an <a href download>, let the click happen, then revoke. Same handle and same cleanup reflex, only the source of the bytes is reversed.

const downloadReport = (csv: string) => {
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'report.csv';
link.click();
URL.revokeObjectURL(url);
};

That synchronous revoke right after link.click() looks like it contradicts the rule against revoking too early, but it doesn’t: a download click captures the bytes before click() returns, so the handle has already done its job by the next line. The <img> case is different because rendering reads the bytes later. The principle is unchanged, revoke once the consumer is done. A download consumer just happens to finish immediately, while an <img> consumer doesn’t.

The upload handoff is where the File you previewed actually goes in production. It belongs to a later lesson, and it’s named here once so you see the seam this lesson builds toward. The picked File doesn’t get uploaded through your server. First the client sends the file’s name, type, and size to a server action, and the action returns a short-lived presigned URL . The client then PUTs the File itself straight to object storage using that URL, so the bytes travel browser-to-storage and your server never touches them. Finally the client tells the server “done, here’s the key” so it can write the metadata row. The File object from this lesson is the body that PUT accepts, which is the whole reason it mattered to get these primitives right.

Server action signs the URL
Client holds the File
Object storage receives the bytes
bytes skip the server

The client’s File goes straight to object storage over a presigned URL, so the bytes skip the server, which only signs the URL and records the result. The upload chapters later in the course build this flow; here, just recognize where the File lands.

Now write the cleanup yourself, since that’s the skill that carries over to every variant of this island. The component below renders a preview correctly but leaks a Blob on every re-pick: the canonical bug, the first draft everyone writes. Make it leak-free without breaking the preview.

This avatar preview leaks a Blob on every re-pick — it mints an object URL in the effect but never revokes the old one. Wire the cleanup so picking a new file (or unmounting) releases the previous URL — without blanking the preview.

Preview

    The middle test guards against revoking too early: doing it on the line right after createObjectURL blanks the preview, so the cleanup belongs in the effect’s return, released on the next pick or on unmount, after the <img> has read the bytes.

    You built one small island, but it’s the shape behind a whole category of product surfaces, and it carries the cleanup reflex with it everywhere it goes.

    One picker, everywhere

    The pick-to-preview island is the same shape behind avatar uploads, attachment thumbnails, logo pickers, and CSV-import previews. Build it once with the revoke wired, and every variant inherits a preview that doesn’t leak.

    The cleanup reflex is the through-line

    revokeObjectURL joins aborting a request and clearing a setTimeout: every resource you open in the browser, you close. Open it, then put the close in the cleanup before you move on.

    Next: the bytes leave the browser

    The File you previewed is the body a presigned PUT uploads straight to object storage, while your server only signs the URL. This lesson built the client half of that upload seam, and the upload chapters build the rest.

    The MDN pages are the reference worth a bookmark for the handle and its cleanup half. The javascript.info chapter is the tutorial-voiced walk through the same chain, with the FileReader-vs-object-URL tradeoff laid out side by side.