File API and Blob Objects

A File picked from an <input> is not data your page owns — it is a handle to bytes that still live on the user’s disk, owned by the browser process, and every API you call on it decides whether those bytes stay there or get copied into a JavaScript heap that has roughly a gigabyte of headroom on a mid-range phone. Getting that decision wrong is the single most common cause of the “Aw, Snap!” tab crash on a 2 GB video upload, and it is entirely avoidable once you know what a Blob actually is.

This guide is the foundation for everything else in upload fundamentals and browser APIs. It covers the memory model, the four ways bytes escape a Blob, a chunked upload built on Blob.slice, and the specific gotchas — empty MIME types, forbidden headers, leaked object URLs, files that vanish mid-upload — that only show up once real users are on the other end.

Anatomy of a File, a Blob and its backing store A File extends Blob, a Blob is an offset and length into a backing store held by the browser process, and slices are additional ranges over the same store. A Blob is a range, not a buffer File name lastModified webkitRelativePath Blob size, type offset + length immutable snapshot backing store on disk, or in the browser process reference-counted, never in your heap paged to a temp file under pressure is a into 0 – 8 MB 8 – 16 MB 16 – 24 MB 24 – 32 MB … 88 more file.slice(start, end) — one small object each, zero bytes copied, zero bytes read Reading, streaming or sending a slice is the only thing that touches the disk. A 700 MB file therefore costs about 90 tiny objects, not 700 MB of heap.
A Blob is an immutable (offset, length, type) triple over a reference-counted backing store — slicing costs allocations measured in bytes, not megabytes.

Prerequisites

  • [ ] A browser build from 2023 or later. Blob.prototype.arrayBuffer(), Blob.prototype.text() and Blob.prototype.stream() are available everywhere that matters; Blob.prototype.bytes() is newer, so feature-detect it.
  • [ ] TypeScript 5.x with "lib": ["DOM", "DOM.Iterable", "ES2022"], or plain ESM. Nothing here needs a bundler plugin.
  • [ ] A secure context (https://, localhost or 127.0.0.1) if you also plan to hash the bytes with Web Crypto checksums.
  • [ ] An upload endpoint that accepts a raw body — an S3-style PUT, or your own handler. Presigned targets are covered in S3 presigned URL workflows.
  • [ ] DevTools open on the Memory and Network panels. Half of this guide is only believable when you watch the numbers.

How it works

The backing store, and why slicing is free

Every Blob in the specification has three pieces of state: a size, a type string, and an internal snapshot of a byte sequence. That snapshot is not stored in your JavaScript heap. In Chromium it lives in the browser process behind a BlobDataHandle, which is reference-counted and can be backed by an in-memory buffer, a file on disk, or a slice of another blob’s store. Firefox and WebKit use different names for the same shape.

A File is simply a Blob with three extra readonly properties — name, lastModified and (for directory pickers) webkitRelativePath. file instanceof Blob is true. There is no conversion step and no cost to treating a File as a Blob, which is why almost every function in this guide takes Blob and happily accepts a File.

blob.slice(start, end, contentType) creates a new Blob that points at a sub-range of the same store. It does not read, copy, or even open the file. On a 700 MB video with 8 MB parts you get roughly 88 JavaScript objects of a few dozen bytes each; heap growth is unmeasurable. The mechanics, including negative offsets and the clamping rules, are worked through in slicing large files with Blob.slice.

The important corollary runs the other way: new Blob([arrayBuffer]) does copy. The constructor takes a snapshot of each part, so building a Blob from a 400 MB ArrayBuffer transiently costs 800 MB — the original buffer plus the store — until the buffer is collected. If you already have a File, never round-trip it through arrayBuffer() and back into a Blob.

What type actually is, and is not

file.type is not sniffed content. The browser maps the file extension through an OS table — the Windows registry under HKEY_CLASSES_ROOT, the shared MIME-info database on Linux, Launch Services on macOS — and reports whatever it finds. When there is no mapping, type is the empty string, and the empty string is a completely legal value that production code sees constantly: .heic on older Windows, .webm on stripped-down Linux images, and almost anything arriving from an Android content provider.

The Blob constructor is stricter than people expect. Per the File API specification, if the supplied type contains any code point outside U+0020–U+007E it is discarded and the blob’s type becomes ""; otherwise the string is lowercased verbatim. It is never validated as a real media type, so new Blob([], { type: "not a mime type" }) succeeds and gives you "not a mime type".

Treat type as a hint for your own UI and nothing else. Server-side you must re-derive it, and client-side you should prefer magic-byte detection before you show a preview. The full argument for distrusting the browser lives in why browser MIME types are unreliable.

The four exits from a Blob

Bytes only leave the backing store through four doors, and the whole memory profile of your uploader is decided by which one you walk through.

The four ways bytes leave a Blob A Blob handle branches into arrayBuffer, stream, a fetch body and createObjectURL, each annotated with its peak JavaScript heap cost. Four exits, four very different memory bills Blob handle offset + length only await blob.arrayBuffer() copies the whole range into the JS heap blob.stream().getReader() pulls one chunk at a time, back-pressured fetch(url, { body: blob }) browser reads from the store, heap untouched URL.createObjectURL(blob) no copy, but pins the store until revoked peak = blob.size peak = one chunk peak ≈ 0 peak ≈ 0 store stays alive
Only the first exit is bounded by device RAM. Handing the Blob straight to fetch keeps the bytes out of your heap entirely.

The first door, arrayBuffer(), is the one every tutorial reaches for and the one you should use least. It is correct for thumbnails, EXIF headers and anything under a few megabytes; it is a crash for a 3 GB .mov. The callback-based alternative and its progress/abort events are covered in reading files with FileReader and ArrayBuffer.

The second door, stream(), returns a ReadableStream<Uint8Array> that the browser fills lazily as you pull. Peak heap is one chunk plus whatever the queuing strategy buffers ahead. It is the right choice when you need to see the bytes on their way past — hashing, re-encoding, or measuring throughput — and it composes with the patterns in Streams API for uploads.

The third door is the one that matters most for uploads: pass the Blob as a fetch body and the bytes never enter your heap at all. The network stack reads the backing store directly, which is why a slice-and-PUT loop can move a 4 GB file through a tab that never exceeds 40 MB of JavaScript memory.

The fourth door, URL.createObjectURL(), mints a blob:https://example.com/<uuid> URL registered against the current origin. It copies nothing, but it adds a reference to the store that survives every route change until you revoke it or the document unloads.

Blobs across threads

Blob and File are structured-cloneable, and cloning one does not copy its bytes — the clone shares the same reference-counted store. worker.postMessage(file) is therefore effectively free regardless of file size, and it is the correct way to move checksum or transcode work off the main thread. Contrast that with postMessage(arrayBuffer), which either copies the whole buffer or detaches it if you list it in the transfer array.

The same property makes Blob a good currency for client-side media preprocessing: send the File into a worker, do the expensive work there, and post a new Blob back. Only the handle crosses the boundary in each direction.

Step-by-step implementation

The worked example below takes a File from an input element and uploads it as ordered parts against presigned PUT URLs, with bounded concurrency, cancellation and no unbounded memory growth. Every block is complete.

1. Normalise and probe the File before you trust it

file.size is reported by the OS and can be a lie — a stale entry for a file that has since been moved, or an iCloud placeholder that has not been materialised. Read one byte first; it is the cheapest possible liveness check.

export interface FileDescriptor {
  name: string;
  size: number;
  declaredType: string;
  lastModified: number;
  readable: boolean;
}

/** Probe a File without loading it. Reads exactly one byte. */
export async function describeFile(file: File): Promise<FileDescriptor> {
  let readable = true;
  try {
    await file.slice(0, 1).arrayBuffer();
  } catch (err) {
    if (err instanceof DOMException && err.name === "NotReadableError") {
      readable = false;
    } else {
      throw err;
    }
  }
  return {
    name: file.name,
    size: file.size,
    declaredType: file.type, // "" is legal and common — do not treat it as an error
    lastModified: file.lastModified,
    readable,
  };
}

On a file that has been deleted or renamed since the picker ran, Chrome rejects with a DOMException whose message is The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired. Catching it here costs one byte of I/O and saves the user from watching a progress bar fail at 97%.

2. Build a slice plan, not an array of slices

Materialising all 88 slice objects up front is harmless, but a plan of plain numbers is easier to persist, retry and reconcile with the server. Create the Blob only at the moment you send it.

export interface PartPlan {
  partNumber: number; // S3 parts are 1-indexed
  start: number;
  end: number; // exclusive
  size: number;
}

const MIN_PART = 5 * 1024 * 1024; // S3's floor for every part except the last

export function planParts(total: number, partSize: number): PartPlan[] {
  if (!Number.isInteger(partSize) || partSize < MIN_PART) {
    throw new RangeError(`partSize must be an integer >= ${MIN_PART}, got ${partSize}`);
  }
  if (total <= 0) return [];

  const parts: PartPlan[] = [];
  for (let start = 0, n = 1; start < total; start += partSize, n += 1) {
    const end = Math.min(start + partSize, total);
    parts.push({ partNumber: n, start, end, size: end - start });
  }
  return parts;
}

// A 700 MB file at 8 MB parts:
const plan = planParts(700 * 1024 * 1024, 8 * 1024 * 1024);
console.log(plan.length, plan.at(-1));
// 88 { partNumber: 88, start: 730001408, end: 734003200, size: 4001792 }

Returning [] for an empty file is deliberate: a zero-byte part is rejected by S3’s CompleteMultipartUpload with EntityTooSmall, so a zero-byte file must take a single ordinary PUT instead. The trade-offs between that single request and a full multipart job are laid out in handling large file size limits.

JavaScript heap during two upload strategies Buffering a 512 MB file into an ArrayBuffer holds half a gigabyte of heap for the whole upload, while an 8 MB slice loop stays flat against the axis. Heap residency uploading a 512 MB file 0 128 256 384 512 MB await file.arrayBuffer() — 512 MB held for the whole transfer slice(start, end) handed to fetch — 8 MB peak, 2.8 px on this scale transfer progress → Same bytes on the wire, same wall-clock time, 64× difference in peak heap.
The buffered strategy is not slower — it is simply the one that runs out of memory first on the devices your users actually own.

3. Send one part without tripping over forbidden headers

export interface PartResult {
  partNumber: number;
  etag: string;
}

export async function putPart(
  file: Blob,
  part: PartPlan,
  url: string,
  signal: AbortSignal,
): Promise<PartResult> {
  // The third argument sets the new Blob's type; slice() otherwise returns type "".
  const body = file.slice(part.start, part.end, "application/octet-stream");

  const res = await fetch(url, {
    method: "PUT",
    body, // no Content-Length: the Fetch spec forbids setting it by hand
    signal,
  });

  if (!res.ok) {
    const detail = await res.text().catch(() => "");
    throw new Error(`part ${part.partNumber}: HTTP ${res.status} ${res.statusText} ${detail}`.trim());
  }

  const etag = res.headers.get("ETag");
  if (etag === null) {
    throw new Error(
      `part ${part.partNumber}: ETag not readable — add ETag to ExposeHeaders in the bucket CORS rule`,
    );
  }
  return { partNumber: part.partNumber, etag: etag.replaceAll('"', "") };
}

Two details earn their keep here. Content-Length is a forbidden header name in the Fetch specification: assigning it is silently dropped, not an error, so code that “sets” it is doing nothing while looking like it works. And a cross-origin fetch cannot read ETag unless the bucket’s CORS rule lists it under ExposeHeaders — the failure surfaces as res.headers.get("ETag") === null with a perfectly successful 200, which is why the check above is explicit rather than an optional chain.

4. Run the plan with bounded concurrency and a real abort path

export interface UploadOptions {
  concurrency?: number;
  signal?: AbortSignal;
  onProgress?: (sentBytes: number, totalBytes: number) => void;
}

export async function uploadParts(
  file: File,
  plan: PartPlan[],
  urlFor: (partNumber: number) => Promise<string>,
  options: UploadOptions = {},
): Promise<PartResult[]> {
  const { concurrency = 4, signal, onProgress } = options;
  const controller = new AbortController();
  const abort = () => controller.abort(signal?.reason);
  signal?.addEventListener("abort", abort, { once: true });

  const total = plan.reduce((sum, p) => sum + p.size, 0);
  const results: PartResult[] = [];
  let sent = 0;
  let cursor = 0;

  async function worker(): Promise<void> {
    while (cursor < plan.length) {
      if (controller.signal.aborted) return;
      const part = plan[cursor++];
      const url = await urlFor(part.partNumber);
      const result = await putPart(file, part, url, controller.signal);
      results.push(result);
      sent += part.size;
      onProgress?.(sent, total);
    }
  }

  try {
    await Promise.all(
      Array.from({ length: Math.min(concurrency, plan.length) }, () => worker()),
    );
  } catch (err) {
    controller.abort(err);
    throw err;
  } finally {
    signal?.removeEventListener("abort", abort);
  }

  return results.sort((a, b) => a.partNumber - b.partNumber);
}

Four concurrent parts is a deliberate default rather than a magic number: it saturates a typical 100 Mbit uplink while keeping the browser’s six-per-origin HTTP/1.1 connection budget clear for your API calls. The shared AbortController means one failed part cancels the rest instead of burning the user’s data allowance on work that will be discarded; the wiring details, including AbortSignal.timeout(), are in aborting uploads with AbortController and timeouts.

The onProgress callback here reports scheduled bytes, not acknowledged ones, because fetch gives you no upload progress events. If you need a real byte counter, either fall back to XMLHttpRequest or wrap the body in a counting stream, as described in tracking upload progress with a TransformStream.

5. Release object URLs deterministically

Previews are where blobs leak. A pool with an explicit lifetime beats scattered revokeObjectURL calls that get skipped on the error path.

export class ObjectUrlPool {
  #urls = new Set<string>();

  create(blob: Blob): string {
    const url = URL.createObjectURL(blob);
    this.#urls.add(url);
    return url;
  }

  release(url: string): void {
    if (this.#urls.delete(url)) URL.revokeObjectURL(url);
  }

  releaseAll(): void {
    for (const url of this.#urls) URL.revokeObjectURL(url);
    this.#urls.clear();
  }

  get outstanding(): number {
    return this.#urls.size;
  }
}

const pool = new ObjectUrlPool();
const img = document.createElement("img");
img.addEventListener("load", () => pool.release(img.src), { once: true });
img.src = pool.create(new Blob([new Uint8Array([255, 216, 255])], { type: "image/jpeg" }));

Revoking on the load event is safe: the image has already decoded by then, and the element keeps rendering. Revoking in the next microtask is not — Chrome will show a broken image if the fetch has not started.

Configuration reference

Key Type Default Effect
new Blob(parts, …).type string "" Lowercased verbatim; reset to "" if it holds any code point outside U+0020–U+007E. Never validated as a real media type.
new Blob(…, { endings }) "transparent" | "native" "transparent" "native" rewrites \n in string parts to the platform line ending. It never touches ArrayBuffer or Blob parts, but it silently changes text payload length.
blob.slice(start) number 0 Negative values count back from the end, exactly like Array.prototype.slice. Out-of-range values clamp instead of throwing.
blob.slice(start, end) number blob.size Exclusive. end <= start yields a legal zero-byte Blob.
blob.slice(start, end, contentType) string "" The only way to keep a MIME type across a slice — omit it and the part uploads with no Content-Type.
fetch(url, { body: blob }) Blob Sets Content-Type from blob.type only when you have not set the header yourself and blob.type is non-empty.
fetch(url, { body: stream }) ReadableStream Requires duplex: "half" and HTTP/2 or HTTP/3; otherwise the request throws TypeError: Failed to execute 'fetch' on 'Window': Request with a ReadableStream body must have the duplex member set.
formData.append(name, blob) Blob filename "blob" Pass a third argument, or every upload arrives on the server named blob. See multipart form data explained.
blob.stream() queuing internal ~64 KB chunks Chunk size is an implementation detail; never assume a fixed value in your reader loop.
xhr.upload.onprogress event The only cross-browser source of true upload byte counts. fetch has no equivalent.
URL.createObjectURL(blob) Blob Registers the blob against the document’s origin and pins the backing store until revokeObjectURL or document unload.

Edge cases and gotchas

The file moved after you acquired the reference

A File from <input type="file"> is a snapshot reference, not a copy. If the user renames, deletes or edits the file mid-upload, the next read rejects with NotReadableError. Long uploads make this likely rather than exotic: a designer re-exports a video into the same path while it is uploading and part 41 fails.

Detect it and re-prompt rather than retrying — retrying will fail identically:

export function isFileGone(err: unknown): boolean {
  return err instanceof DOMException &&
    (err.name === "NotReadableError" || err.name === "NotFoundError");
}

Safari uses NotFoundError for the same condition, and for iCloud-optimised files it can report a plausible size while every read returns zero bytes.

Empty blob.type breaks presigned signatures

When blob.type is "", fetch sends no Content-Type header at all. If the presigned URL was signed with a Content-Type, S3 answers 403 with SignatureDoesNotMatch and the body The request signature we calculated does not match the signature you provided. The reverse is just as common: you sign nothing and the browser adds Content-Type: image/jpeg from a File that happened to have a type.

Pin it on both sides. Decide the content type in one place, sign it there, and force the same value onto the slice:

export function typedSlice(file: Blob, start: number, end: number, contentType: string): Blob {
  if (!/^[\x20-\x7e]+$/.test(contentType)) {
    throw new TypeError(`contentType must be printable ASCII, got ${JSON.stringify(contentType)}`);
  }
  return file.slice(start, end, contentType);
}

endings: "native" corrupts text payloads

endings: "native" exists to make new Blob(["a\nb"], { endings: "native" }) produce a\r\nb on Windows. It only rewrites string parts — binary parts pass through untouched — but that is enough to break a checksum you computed before construction, and enough to change blob.size by one byte per line. Leave it at "transparent" unless you are deliberately generating a Windows text file, and never combine it with a hash computed over the original string.

Object URLs that outlive their route

Lifetime of a blob URL A blob URL moves from created to pinned to revoked to collected, with a leak branch when revoke never runs and an automatic release on document unload. Lifetime of a blob: URL createObjectURL blob: URL minted pinned in registry store cannot be freed revokeObjectURL entry removed GC reclaims bytes heap and disk freed leaked for the session bytes survive every route change registry cleared only on document unload no revoke tab closed In a single-page app the document never unloads — the only exit is an explicit revoke.
Blob URLs are released on document unload — which, in a client-side router, effectively means never.

A gallery that mints one preview URL per selected photo and re-renders on every route change accumulates stores at full file size. Twenty 12 MP JPEGs re-selected five times is roughly 400 MB pinned in the browser process, invisible to a heap snapshot because none of it is in the JavaScript heap. Open chrome://blob-internals and you will see every outstanding handle with its refcount and byte size — it is the fastest way to prove the leak.

Zero-byte and off-by-one final parts

blob.slice(1000, 500) does not throw; it returns a Blob of size 0. Combined with a loop that computes end as start + partSize without clamping, this produces an empty trailing part, and S3 rejects the completion with EntityTooSmall: Your proposed upload is smaller than the minimum allowed size. Always derive end with Math.min(start + partSize, total) as in step 2, and assert that plan.reduce((s, p) => s + p.size, 0) === file.size before you send anything.

getAsFile() must be called synchronously

Files arriving via drag-and-drop come through DataTransferItem.getAsFile(), and the DataTransfer object is only valid during the drop event’s synchronous execution. Awaiting anything before you call getAsFile() returns null. Grab every File first, then start async work — the full pattern, including dropped directories, is in drag-and-drop file uploads.

The 512 MB string wall

FileReader.readAsDataURL() and readAsText() produce JavaScript strings, and V8 caps a single string at 2^29 − 24 characters — about 536 million — on 64-bit builds. Because Base64 inflates by 4/3, a data URL therefore dies somewhere around a 400 MB source file with RangeError: Invalid string length, well before you run out of RAM. That ceiling, and the 33% bandwidth tax that comes with it, is the argument in Base64 vs binary encoding for never encoding upload bodies at all.

Verification

Paste this into the DevTools console with a file selected in an <input type="file" id="f">. It asserts the four behaviours that most often surprise people, and prints nothing when everything holds.

const file = document.querySelector("#f").files[0];

console.assert(file instanceof Blob, "File does not extend Blob");
console.assert(file.slice(0, 1024).size === Math.min(1024, file.size), "slice size wrong");
console.assert(file.slice(0, 1024).type === "", "slice() should drop type unless given one");
console.assert(file.slice(10, 5).size === 0, "reversed range should clamp to zero, not throw");
console.assert(
  new Blob([], { type: "IMAGE/JPEG©" }).type === "",
  "non-ASCII in type should reset it to empty",
);
console.log("blob invariants OK —", file.name, file.size, JSON.stringify(file.type));

To prove the bytes never reach your heap, take a heap snapshot in the Memory panel, run a full slice-and-PUT upload of a 500 MB file, and take a second snapshot. The delta should be a few megabytes of promises and result objects. Then open chrome://blob-internals in a second tab: during the upload you will see one handle at the full file size with a refcount reflecting the outstanding slices, and zero handles once it completes.

Finally, confirm what actually went on the wire rather than what you intended. In the Network panel, select a part request and check that Content-Type matches what your presigning code signed and that Content-Length equals the part size your plan predicted — if Content-Length is missing or wrong, you set it by hand and the browser dropped it. The same request-shaping rules apply to every transport described in Modern Fetch API for uploads.

Frequently Asked Questions

Does Blob.slice() read anything from disk?

No. It allocates a small object holding an offset, a length and a type, all pointing at the same backing store. The first disk read happens when you call arrayBuffer(), text(), stream(), or hand the slice to fetch — which is why building a plan of 500 slices up front is cheap and building 500 ArrayBuffers is not.

Why is file.type an empty string for some users?

Because the browser derives it from an operating-system extension table rather than from the file’s contents. Unregistered extensions, files from Android content providers and files renamed without an extension all produce "". Treat it as advisory and sniff the magic bytes when the type matters.

Can I mutate a Blob in place?

No — blobs are immutable by specification, which is exactly what makes slicing and cross-thread sharing free. To “modify” one, construct a new Blob from the parts you want: new Blob([header, file.slice(offset)], { type: file.type }) is a cheap concatenation that copies only the small header.

Should I keep the original File alive after upload?

Only if you need to retry. A File reference keeps the backing store’s refcount above zero, and in Chromium a store that was paged out occupies a temp file until every handle drops. Clear the reference — and revoke any object URLs — as soon as the last part is acknowledged.

Is new Blob([arrayBuffer]) free like slicing is?

No, that direction copies. The constructor snapshots each part into a fresh backing store, so a 400 MB ArrayBuffer briefly costs 800 MB across the heap and the store. If your bytes started life as a File, keep them as a File and slice; only construct blobs from buffers you actually generated in JavaScript.