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 β
FileReaderexposesprogressandabort(), which the promise-basedBlobmethods 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
- A
FileorBlobreference β from an<input type="file">, a drag-and-drop drop zone, a paste handler, orfetch. - TypeScript 5.x with
lib: ["DOM", "DOM.Iterable", "ES2022"], or plain ESM JavaScript. - A browser shipping
Blob.arrayBuffer(),.text()and.stream()β all three are available across current Chromium, Firefox and Safari.crypto.subtleadditionally requires a secure context (https:orlocalhost).
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.
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 anArrayBufferβ an opaque byte store with no indexing. You must wrap it in aUint8Array(orDataView) before you can touch a byte.reader.onloadfires once;reader.resultisnulluntil it does, and its static type isstring | ArrayBuffer | null, so the cast is load-bearing.readAsArrayBufferguarantees theArrayBufferbranch.event.lengthComputableguards the division. When it isfalse,event.totalis0andloaded / totalisNaN, which silently produces a zero-width progress bar.reader.abort()is the only way to stop an in-flight read. Bridging it to anAbortSignalkeeps the call site consistent with the rest of your upload code β the same pattern used when aborting uploads with AbortController.releaseLock()in thefinallyblock matters if you plan totee()or re-read the stream; a locked stream throwsTypeError: Failed to execute 'getReader' on 'ReadableStream': Invalid state: ReadableStream is locked.
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.
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.
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.