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, and switch to blob.stream() once files grow large enough that buffering the whole thing 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.

When to use this approach

  • You need a file’s contents in memory as an ArrayBuffer, Uint8Array, or string (hashing, parsing headers, image decoding).
  • You want a live progress bar while reading a large file from disk β€” FileReader exposes progress events that the promise-based Blob methods do not.
  • You are processing files too large to hold in RAM and want to stream them through a transform instead.

Prerequisites

  1. A File or Blob reference β€” from an <input>, a drag-and-drop drop zone, or fetch.
  2. TypeScript with lib: ["DOM", "DOM.Iterable", "ES2022"].
  3. A modern browser; Blob.arrayBuffer(), .text(), and .stream() are supported across Chromium, Firefox, and Safari.

Implementation

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

// 1. Default: promise-based, no event plumbing.
async function readBytes(file: Blob): Promise<Uint8Array> {
  const buffer: ArrayBuffer = await file.arrayBuffer();
  return new Uint8Array(buffer);
}

async function readText(file: Blob): Promise<string> {
  return file.text(); // decodes as UTF-8
}

// 2. FileReader: use ONLY when you need progress events.
function readWithProgress(
  file: Blob,
  onProgress: (fraction: number) => void,
): Promise<ArrayBuffer> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result as ArrayBuffer);
    reader.onerror = () => reject(reader.error ?? new Error("read failed"));
    reader.onprogress = (event: ProgressEvent<FileReader>) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    reader.readAsArrayBuffer(file);
  });
}

// 3. Stream: process a large file chunk by chunk, never holding it all.
async function hashStreamed(file: Blob): Promise<string> {
  const reader = file.stream().getReader();
  const chunks: Uint8Array[] = [];
  let total = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    total += value.byteLength;
  }
  // Concatenate only the parts you keep; here we hash incrementally instead.
  const merged = new Uint8Array(total);
  let offset = 0;
  for (const chunk of chunks) {
    merged.set(chunk, offset);
    offset += chunk.byteLength;
  }
  const digest = await crypto.subtle.digest("SHA-256", merged);
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

Usage:

const input = document.querySelector<HTMLInputElement>("#file")!;
input.addEventListener("change", async () => {
  const file = input.files?.[0];
  if (!file) return;
  const bytes = await readBytes(file);
  console.log("read", bytes.byteLength, "bytes; first 4:", [...bytes.slice(0, 4)]);
  await readWithProgress(file, (f) => console.log(`reading ${(f * 100).toFixed(0)}%`));
});

Line-by-line on the critical parts

  • blob.arrayBuffer() resolves to an ArrayBuffer; wrap it in a Uint8Array to index individual bytes (useful for checking magic numbers like 0xFF 0xD8 for JPEG).
  • FileReader.readAsArrayBuffer() is asynchronous despite the imperative call β€” results arrive on onload, and reader.result is null until then. Always read it inside the handler.
  • event.lengthComputable guards the progress math; when false, total is 0 and dividing yields NaN. Only update the bar when it’s true.
  • blob.stream() returns a ReadableStream<Uint8Array>; reading it incrementally means you never allocate one buffer the size of the whole file β€” essential past a few hundred megabytes.

The promise methods are syntactic wrappers over the same underlying read FileReader performs; choose FileReader purely for its progress event surface. When the file is genuinely large, prefer the stream so you can pipe it straight into an upload β€” pair this with slicing large files with Blob.slice and send the parts as multipart form data.

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() small, no progress the default FileReader need a progress bar onprogress events 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 events, and stream when the file is too large to buffer.

Configuration gotchas

Reading reader.result before onload returns null. FileReader is asynchronous; the imperative readAsArrayBuffer() only kicks off the read. Accessing reader.result synchronously after the call yields null. Read it inside onload.

Progress math produces NaN. When event.lengthComputable is false, event.total is 0. Guard every progress calculation with if (event.lengthComputable) to avoid a NaN width on your bar.

readAsDataURL inflates binary by ~33%. Using readAsDataURL to upload media base64-encodes it, adding roughly a third to the payload. For transmission, keep the binary File/Blob β€” see base64 vs binary encoding. Use data URLs only for inline previews.

Buffering a huge file crashes mobile tabs. arrayBuffer() on a 1 GB file allocates 1 GB; iOS Safari kills tabs over ~1 GB heap. Use stream() and process chunks for anything large.

Testing / verification

Assert the byte count and a known magic number without a server:

async function verifyJpeg(file: File): Promise<void> {
  const bytes = new Uint8Array(await file.arrayBuffer());
  console.assert(bytes.byteLength === file.size, "byte length mismatch");
  console.assert(
    bytes[0] === 0xff && bytes[1] === 0xd8,
    "not a JPEG (missing FFD8 SOI marker)",
  );
  console.log("verified", file.name, bytes.byteLength, "bytes");
}

FAQ

Should I use FileReader or Blob.arrayBuffer() in new code?

Use await blob.arrayBuffer() (or .text()) by default β€” it is shorter and promise-based. Reach for FileReader only when you need its progress events to drive a live reading indicator.

Why is FileReader.result null right after readAsArrayBuffer?

The read is asynchronous. readAsArrayBuffer() merely starts it; the bytes appear in reader.result when the onload event fires. Read the result inside that handler, never immediately after the call.

How do I read a file that’s too big to fit in memory?

Call blob.stream() and consume the ReadableStream chunk by chunk, processing or uploading each chunk before discarding it. This keeps peak memory flat regardless of file size.

When should I use readAsDataURL?

Only for inline previews (e.g. an <img src> thumbnail). For uploading, data URLs base64-encode the file and add about 33% overhead, so send the raw binary instead.