Reading Files with FileReader and ArrayBuffer

For new code, read a file’s bytes with await blob.arrayBuffer() (or .text()), reach for the callback-based FileReader only when you need granular progress events or a cancel handle, and switch to blob.stream() once the file is large enough that holding it all in memory becomes a risk.

This article is part of the File API and Blob objects topic inside upload fundamentals and browser APIs. It covers how to turn a File β€” however you obtained it β€” into usable bytes, and what that costs.

When to use this approach

  • You need a file’s contents in memory as an ArrayBuffer, Uint8Array, or string: hashing, sniffing magic bytes, parsing container headers, decoding an image.
  • You want a live indicator while reading from disk, or the ability to cancel mid-read β€” FileReader exposes progress and abort(), which the promise-based Blob methods do not.
  • You are processing files too large to hold in RAM and want to push them through a transform or straight onto the wire β€” see Streams API for uploads.

If all you want is to send the file, do not read it at all. fetch(url, { body: file }) and FormData.append("file", file) both consume the File reference directly; the browser streams it off disk for you. Reading first only adds a full copy to the heap.

Prerequisites

  1. A File or Blob reference β€” from an <input type="file">, a drag-and-drop drop zone, a paste handler, or fetch.
  2. TypeScript 5.x with lib: ["DOM", "DOM.Iterable", "ES2022"], or plain ESM JavaScript.
  3. A browser shipping Blob.arrayBuffer(), .text() and .stream() β€” all three are available across current Chromium, Firefox and Safari. crypto.subtle additionally requires a secure context (https: or localhost).

How the read actually works

A File object is not bytes. It is a handle: a name, a size, a type, a lastModified timestamp, and an internal reference to a backing store β€” usually a path on disk, sometimes an in-memory buffer. Constructing one is free no matter how large the file is, which is why input.files[0] returns instantly for a 4 GB video.

The spec attaches a snapshot state to that handle at creation time. The size and modification time recorded then are the ones every later read is validated against. If the user edits or deletes the underlying file after picking it, the handle does not update β€” the next read fails instead.

Every read path, including the promise-based ones, is defined in terms of the same underlying read operation: the user agent opens the backing store on a parallel thread and queues tasks back onto the event loop as bytes arrive. FileReader surfaces those tasks as DOM events; blob.arrayBuffer() collects them into a promise; blob.stream() hands them to you one chunk at a time. There is no separate fast path β€” the difference is purely in the interface you get.

FileReader moves through three readyState values: 0 (EMPTY), 1 (LOADING) and 2 (DONE). It fires loadstart, then zero or more progress events, then exactly one terminal event (load, error, or abort), then always loadend. The File API caps the progress cadence at roughly one event per 50 ms, so a 12 MB file coming out of the OS page cache frequently produces a single progress event β€” or none at all β€” before load.

FileReader readyState transitions and event order A three-state machine from EMPTY to LOADING to DONE, above a timeline showing loadstart, throttled progress events, load, and loadend. FileReader lifecycle EMPTY (0) result === null LOADING (1) busy β€” reads throw DONE (2) result or error set readAs*() onload abort() and error() also land in DONE β€” a new reader is cheaper than resetting one Event order on one 12 MB read throttled to ~50 ms apart loadstart progress progress progress load loadend loadend always fires last, whether the read succeeded, errored, or was aborted.
One terminal event and one loadend per read β€” attach cleanup to loadend, not to load.

Implementation

The three reading paths solve different problems. The promise-based Blob methods are the default. FileReader adds progress and cancellation. stream() avoids holding the whole file at once.

/** 1. Default: promise-based, no event plumbing. */
export async function readBytes(blob: Blob): Promise<Uint8Array> {
  return new Uint8Array(await blob.arrayBuffer());
}

export async function readText(blob: Blob): Promise<string> {
  return blob.text(); // always UTF-8, BOM-sniffed
}

/** 2. FileReader: only when you need progress events or an abort handle. */
export function readWithProgress(
  blob: Blob,
  onProgress: (fraction: number) => void,
  signal?: AbortSignal,
): Promise<ArrayBuffer> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result as ArrayBuffer);
    reader.onerror = () =>
      reject(reader.error ?? new DOMException("read failed", "NotReadableError"));
    reader.onabort = () => reject(new DOMException("read aborted", "AbortError"));
    reader.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    signal?.addEventListener("abort", () => reader.abort(), { once: true });
    reader.readAsArrayBuffer(blob);
  });
}

/** 3. Stream: constant memory, whatever the file size. */
export async function countBytesStreamed(blob: Blob): Promise<number> {
  const reader = blob.stream().getReader();
  let total = 0;
  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) return total;
      total += value.byteLength; // process and discard β€” never retain the chunk
    }
  } finally {
    reader.releaseLock();
  }
}

Wire it to an input:

const input = document.querySelector<HTMLInputElement>("#file")!;
const controller = new AbortController();

input.addEventListener("change", async () => {
  const file = input.files?.[0];
  if (!file) return;
  const bytes = await readBytes(file);
  console.log(`${file.name}: ${bytes.byteLength} bytes, first four: `,
    [...bytes.subarray(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join(" "));
  await readWithProgress(file, (f) => console.log(`read ${(f * 100).toFixed(0)}%`),
    controller.signal);
});

Line-by-line on the critical parts

  • blob.arrayBuffer() resolves to an ArrayBuffer β€” an opaque byte store with no indexing. You must wrap it in a Uint8Array (or DataView) before you can touch a byte.
  • reader.onload fires once; reader.result is null until it does, and its static type is string | ArrayBuffer | null, so the cast is load-bearing. readAsArrayBuffer guarantees the ArrayBuffer branch.
  • event.lengthComputable guards the division. When it is false, event.total is 0 and loaded / total is NaN, which silently produces a zero-width progress bar.
  • reader.abort() is the only way to stop an in-flight read. Bridging it to an AbortSignal keeps the call site consistent with the rest of your upload code β€” the same pattern used when aborting uploads with AbortController.
  • releaseLock() in the finally block matters if you plan to tee() or re-read the stream; a locked stream throws TypeError: Failed to execute 'getReader' on 'ReadableStream': Invalid state: ReadableStream is locked.
Choosing between arrayBuffer, FileReader, and stream A decision flow from a File to one of three reading strategies based on size and progress needs. Which reading API to use File / Blob blob.arrayBuffer() under ~50 MB the default FileReader need progress or abort() blob.stream() large file never buffer all Memory cost rises left to right; streaming keeps peak RAM flat.
Pick arrayBuffer by default, FileReader for progress or cancellation, and stream when the file is too large to buffer.

Reading API reference

Peak memory is expressed as a multiple of the file’s size, counting only what the read itself allocates.

API Returns Progress Cancel Peak memory Notes
blob.arrayBuffer() Promise<ArrayBuffer> no no 1Γ— The default for anything you can afford to buffer.
blob.bytes() Promise<Uint8Array> no no 1Γ— Newer sugar; skips the new Uint8Array() wrap. Feature-detect before relying on it.
blob.text() Promise<string> no no β‰ˆ2Γ— UTF-8 only. A JS string is UTF-16, so ASCII text doubles in RAM.
blob.stream() ReadableStream<Uint8Array> per chunk reader.cancel() chunk size The only flat-memory path.
FileReader.readAsArrayBuffer(blob) via onload yes abort() 1Γ— Progress capped near one event per 50 ms.
FileReader.readAsText(blob, label) via onload yes abort() β‰ˆ2Γ— The second argument picks the encoding, e.g. "windows-1252".
FileReader.readAsDataURL(blob) via onload yes abort() β‰ˆ2.7Γ— Base64 in a UTF-16 string. Previews only β€” see base64 vs binary encoding.
new FileReaderSync() value, blocking no no 1Γ— Workers only; ReferenceError on the main thread.

readAsBinaryString still exists and should not be used: it returns a latin-1 string where each code unit holds one byte, which corrupts silently the moment anything treats it as text.

Working with the bytes you got

An ArrayBuffer is storage; a typed array is a window onto that storage. Two views over the same buffer see the same bytes, and writing through one is immediately visible through the other. This is what makes header parsing cheap: you never copy, you just look.

Typed array views over the first sixteen bytes of a JPEG Sixteen byte cells of a JFIF header with four annotation bands showing which views cover which byte ranges. One ArrayBuffer, many views β€” no copies FF D8 FF E0 00 10 4A 46 49 46 00 01 01 00 00 48 0 2 4 6 8 10 12 14 new Uint8Array(buffer) β€” 16 elements, zero copy bytes[0] 0xFF, bytes[1] 0xD8 β€” the JPEG SOI marker view.getUint16(4) === 16 β€” APP0 segment length decode(bytes.subarray(6, 10)) === "JFIF" subarray() shares the buffer; slice() allocates a fresh copy.
Header parsing costs nothing extra β€” every view above points at the same sixteen bytes.
export interface JpegHeader {
  isJpeg: boolean;
  app0Length: number;
  tag: string;
}

export function inspectHeader(buffer: ArrayBuffer): JpegHeader {
  const bytes = new Uint8Array(buffer); // view, no allocation
  const view = new DataView(buffer);    // view, no allocation
  return {
    isJpeg: bytes[0] === 0xff && bytes[1] === 0xd8,
    app0Length: view.getUint16(4),      // DataView is big-endian by default
    tag: new TextDecoder("ascii").decode(bytes.subarray(6, 10)),
  };
}

Three rules follow from the view model. DataView is the only view that lets you choose endianness per call β€” getUint16(4, true) reads little-endian, which is what you need for RIFF and most video container boxes. subarray() returns another window; slice() copies. And a multi-byte typed array such as Uint32Array requires an aligned offset, so new Uint32Array(buffer, 3) throws RangeError: start offset of Uint32Array should be a multiple of 4. Read through a DataView when offsets are arbitrary.

Memory behaviour under load

Buffering is linear in file size; streaming is not. The chart below plots peak JavaScript heap attributable to the read itself, measured in Chrome 126 on a desktop with the Performance monitor open, on a log scale so both series are visible.

Peak heap for buffered versus streamed reads A log-scale bar chart comparing peak memory of blob.arrayBuffer against blob.stream across file sizes from 10 MB to 1 GB. Peak read heap, log scale buffered (arrayBuffer) streamed (blob.stream) 1 MB 8 MB 64 MB 512 MB 10 50 200 500 1024 10 MB 50 MB 200 MB 500 MB 1 GB Streamed reads hold about 2 MB β€” one part buffer β€” no matter how big the file is.
Buffering tracks file size exactly; streaming is flat, which is why it is the only safe option on mobile.

The awkward part is that Web Crypto has no incremental digest API: crypto.subtle.digest() is one-shot and wants every byte at once. So β€œstream it and hash it” is not directly possible with platform APIs alone. The production answer is a composite digest β€” hash fixed-size parts, then hash the concatenated part digests, exactly the way S3 computes a multipart ETag. It keeps peak memory at one part and produces a value you can compare part-by-part against the server.

const PART_SIZE = 8 * 1024 * 1024; // 8 MiB β€” align this with your upload part size

export async function compositeDigest(blob: Blob): Promise<string> {
  const reader = blob.stream().getReader();
  const part = new Uint8Array(PART_SIZE);
  const partDigests: Uint8Array[] = [];
  let filled = 0;

  const flush = async (): Promise<void> => {
    // subtle.digest copies its input synchronously, so reusing `part` is safe.
    const digest = await crypto.subtle.digest("SHA-256", part.subarray(0, filled));
    partDigests.push(new Uint8Array(digest));
    filled = 0;
  };

  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    let offset = 0;
    while (offset < value.byteLength) {
      const take = Math.min(PART_SIZE - filled, value.byteLength - offset);
      part.set(value.subarray(offset, offset + take), filled);
      filled += take;
      offset += take;
      if (filled === PART_SIZE) await flush();
    }
  }
  if (filled > 0 || partDigests.length === 0) await flush();

  const joined = new Uint8Array(partDigests.length * 32);
  partDigests.forEach((d, i) => joined.set(d, i * 32));
  const final = new Uint8Array(await crypto.subtle.digest("SHA-256", joined));
  const hex = [...final].map((b) => b.toString(16).padStart(2, "0")).join("");
  return `${hex}-${partDigests.length}`;
}

The re-buffering loop exists because you do not control chunk sizes: Chromium hands back roughly 64 KiB at a time and other engines differ, so accumulate to your own boundary before doing anything size-sensitive. If you need a plain end-to-end hash rather than a composite one, computing file checksums with Web Crypto covers the trade-offs; if you want the parts on the wire as you produce them, feed them to a ReadableStream request body or cut them with Blob.slice.

Configuration gotchas

The file changed on disk after the user picked it

The snapshot state no longer matches, and the read fails rather than returning stale bytes. Chrome throws NotReadableError: The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired.; Firefox surfaces the same condition as a NotFoundError. This is common with files on network shares and with screenshots that a tool rewrites in place. Catch it, tell the user the file changed, and ask them to re-select it β€” you cannot refresh the handle from JavaScript.

Reusing one FileReader for two reads

Calling a second readAs*() while readyState === 1 throws InvalidStateError: Failed to execute 'readAsArrayBuffer' on 'FileReader': The object is already busy reading Blobs. A FileReader is a few hundred bytes; construct a fresh one per read instead of trying to sequence them, and if you are reading a queue of files, await each promise before starting the next.

The ArrayBuffer ceiling and mobile tab kills

64-bit Chrome caps a single ArrayBuffer at just under 4 GiB, but you will hit the device wall long before the spec wall. Over-allocating throws RangeError: Array buffer allocation failed on desktop; on iOS Safari there is no exception at all β€” the tab is terminated and reloads blank, typically somewhere past 1 GB of heap. Treat anything above ~200 MB as stream-only, and keep 500 MB uploads off the buffered path entirely.

Detached buffers after a worker transfer

worker.postMessage(buffer, [buffer]) transfers ownership. The sending realm’s ArrayBuffer is detached: byteLength becomes 0 and any new view throws TypeError: Cannot perform Construct on a detached ArrayBuffer. Either transfer and forget, or omit the transfer list and pay for a structured-clone copy. Passing the File itself is cheaper than either β€” a File clones as a reference to its backing store, so the bytes never cross the thread boundary.

readAsText and the replacement character

blob.text() and readAsText() both replace malformed sequences with U+FFFD instead of failing, so a mis-labelled CSV silently becomes garbage. When correctness matters, read bytes and decode strictly: new TextDecoder("utf-8", { fatal: true }).decode(bytes) throws TypeError: Failed to execute 'decode' on 'TextDecoder': The encoded data was not valid. and you can fall back to "windows-1252" from there.

Zero-byte files and non-computable progress

A 0-byte file is legal β€” users drop them constantly from cloud-sync folders. FileReader fires load with an empty ArrayBuffer and no useful progress event, and lengthComputable may be false. Validate file.size > 0 at selection time rather than discovering it three network round-trips later; the same check belongs in your server-side validation because a client can lie about both.

Verification

Prove that both read paths agree and that neither loses bytes, with no server involved. Paste this into DevTools with a file input on the page:

async function sha256Hex(bytes: Uint8Array): Promise<string> {
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

export async function verifyReadPaths(file: File): Promise<void> {
  const buffered = new Uint8Array(await file.arrayBuffer());
  console.assert(
    buffered.byteLength === file.size,
    `size mismatch: read ${buffered.byteLength}, expected ${file.size}`,
  );

  let streamed = 0;
  const reader = file.stream().getReader();
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    streamed += value.byteLength;
  }
  console.assert(streamed === file.size, `stream yielded ${streamed} of ${file.size} bytes`);

  const viaResponse = new Uint8Array(await new Response(file.stream()).arrayBuffer());
  const [a, b] = await Promise.all([sha256Hex(buffered), sha256Hex(viaResponse)]);
  console.assert(a === b, "buffered and streamed reads disagree");
  console.log(`${file.name}: ${file.size} bytes, sha256 ${a.slice(0, 16)}…`);
}

A clean run logs one line and no assertion failures. To confirm the memory claim, open the Chrome Performance monitor, watch the JS heap size trace while you run file.arrayBuffer() on a 500 MB file, then run countBytesStreamed(file) on the same file: the first produces a half-gigabyte step, the second stays flat. That difference is the whole argument for streaming β€” and the reason resumable flows that persist upload state store offsets rather than bytes.

Frequently Asked Questions

Is blob.arrayBuffer() just FileReader with a promise wrapper?

Functionally yes, and specification-wise close: arrayBuffer() is defined as reading the blob’s stream to completion, which is the same read operation FileReader drives. What you give up is the event surface β€” no loadstart, no progress, no abort(). What you gain is that the result cannot be observed in a half-finished state.

Does reading the same file twice hit the disk twice?

Yes. Nothing caches blob contents for you; each read re-opens the backing store, and each arrayBuffer() call allocates a fresh buffer. If you need the bytes more than once, keep the Uint8Array in a variable, and for image previews use URL.createObjectURL(file) β€” it renders without reading anything into your heap, provided you call URL.revokeObjectURL() afterwards.

Can I read files inside a Web Worker?

Yes, and it is the right place for anything CPU-heavy such as stripping EXIF metadata. FileReaderSync exists only in workers and blocks that thread, which is fine because it is not the thread painting your UI. Send the File across with postMessage β€” it is cloned as a reference, so a 2 GB file costs the same to hand over as a 2 KB one.

Why does my progress bar jump straight from 0% to 100%?

Because the read finished inside one 50 ms window. Local reads are served from the OS page cache at gigabytes per second, so files under roughly 50 MB often produce one progress event or none. Reading progress is rarely worth showing at all β€” the number users care about is bytes on the wire, covered by accurate time-remaining estimates.

How large are the chunks blob.stream() gives me?

You do not get to choose, and you must not depend on it: Chromium currently yields up to about 64 KiB per read(), other engines pick different sizes, and the last chunk is always short. If your logic needs fixed boundaries β€” part uploads, block ciphers, fixed-width records β€” accumulate into a buffer of your own size, as the composite digest above does.