Files and Binary Data in JavaScript: File, Blob, ArrayBuffer and Typed Arrays
We take files from the user via input and drag and drop, read Blobs and Files, go down to bytes with ArrayBuffer, typed arrays and DataView, and learn ES2026's base64/hex methods and file downloads.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
Let's start with one of the heroes of this post. We create a list of four numbers, but not a plain array:
const bytes = new Uint8Array([255, 256, 257, -1]);
console.log(bytes);
What does this code print to the console? Do the four numbers stay as they are, do we get an error, or does something else happen? Make a guess before you keep reading. The answer will reveal itself once we see how typed arrays work with bytes.
Blob and File: A Box of Raw Data
So far the data we've worked with has always been JavaScript values: strings, numbers, objects. A photo, a PDF or a voice recording coming from the user's computer, on the other hand, is just a pile of bytes. The browser puts that pile into an object called a Blob (Binary Large Object).
Think of a Blob as a sealed shipping box. Its weight (size) and its label (type) are written on the outside, but to see what's inside you have to open it, and opening it takes time. That's why the methods that read the content return promises. Paste this into your browser console and try it:
const blob = new Blob(["Hello ", "café"], { type: "text/plain" });
console.log(blob.size, blob.type); // 11 "text/plain"
const text = await blob.text();
console.log(text); // "Hello café"
"Hello café" has 10 characters, yet size says 11. That's because size counts bytes, not characters; strings are written into a Blob as UTF-8, and "é" takes two bytes in UTF-8. You can get the data out of the box in three ways: text() as text, arrayBuffer() as raw bytes, and stream() piece by piece as the ReadableStream we saw earlier. slice(start, end) cuts a part of the box into a new Blob without opening it, which is very handy when you only want to look at the beginning of a large file.
File is a subtype of Blob. It has everything a Blob has and adds name and lastModified on top. Every file the user picks arrives as a File, so everything you learn about Blobs applies to files too.
Getting Files from the User
For security reasons the browser can't reach into the file system on its own; the user has to hand the file over. The simplest way is <input type="file">. A complete example:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Reading files</title>
</head>
<body>
<input type="file" id="picker" accept=".txt,.csv,.json" multiple>
<pre id="output"></pre>
<script>
const picker = document.getElementById("picker");
const output = document.getElementById("output");
picker.addEventListener("change", async () => {
output.textContent = "";
for (const file of picker.files) {
const text = await file.text();
const kb = (file.size / 1024).toFixed(1);
output.textContent += `${file.name} (${kb} KB, ${file.type || "unknown type"})\n`;
output.textContent += text.slice(0, 200) + "\n\n";
}
});
</script>
</body>
</html>
picker.files is a FileList; it isn't an array, but you can loop over it with for...of. accept only sets the filter in the file picker, and the user can still choose a different file. So always check the incoming file yourself.
In older code you'll see the same job done with FileReader:
const reader = new FileReader();
reader.addEventListener("load", () => console.log(reader.result));
reader.addEventListener("error", () => console.error(reader.error));
reader.readAsText(file);
FileReader is event based and has methods like readAsText, readAsArrayBuffer and readAsDataURL. It's still supported everywhere, but in new code the promise-returning text() and arrayBuffer() are both shorter and a more natural fit with async/await.
Drag and Drop with a Preview
Dragging a file onto an area and dropping it feels more comfortable to users than an input. The file below has a drop zone and an image preview:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Drag and drop</title>
<style>
#zone { border: 3px dashed #888; padding: 40px; text-align: center; }
#zone.over { border-color: #2563eb; background: #eff6ff; }
#preview img { max-width: 200px; margin: 8px; }
</style>
</head>
<body>
<div id="zone">Drop images here</div>
<div id="preview"></div>
<script>
const zone = document.getElementById("zone");
const preview = document.getElementById("preview");
zone.addEventListener("dragover", (event) => {
event.preventDefault(); // without this, drop never fires
zone.classList.add("over");
});
zone.addEventListener("dragleave", () => zone.classList.remove("over"));
zone.addEventListener("drop", (event) => {
event.preventDefault(); // stops the browser from opening the file
zone.classList.remove("over");
for (const file of event.dataTransfer.files) {
if (!file.type.startsWith("image/")) continue;
const img = document.createElement("img");
img.src = URL.createObjectURL(file);
img.alt = file.name;
img.addEventListener("load", () => URL.revokeObjectURL(img.src));
preview.append(img);
}
});
</script>
</body>
</html>
Both preventDefault() calls are essential. If you don't cancel the default on dragover, the browser doesn't treat the area as a valid drop target; if you don't cancel it on drop, it opens the image you dropped in a new tab and your page is gone. Dropped files arrive in event.dataTransfer.files, again as a FileList.
The secret of the preview is URL.createObjectURL(file). This method produces a short address that starts with blob: and is valid only on this page. The address isn't a copy of the file; it's a pointer to the Blob in memory, so it's ready instantly even for a very large image. The price is that the browser keeps that Blob in memory until you call URL.revokeObjectURL(). We release the address once the image has loaded. Alternatively, you could turn the image into base64 text with readAsDataURL, but that text is roughly a third larger than the file and an unnecessary burden for a preview.
ArrayBuffer and Typed Arrays
When you want to read a Blob's content byte by byte, arrayBuffer() gives you an ArrayBuffer. An ArrayBuffer is a raw block of memory with a fixed length, and you can't read it on its own. To look at it you need to put on a pair of "glasses"; those glasses are called typed arrays.
Picture four boxes lined up in a row. The Uint8Array glasses look at each box separately and see a number between 0 and 255 in each. The Uint16Array glasses look at the boxes two at a time and read each pair as a single number. Same boxes, different view. Try it in the console:
const buffer = new ArrayBuffer(4);
const bytes = new Uint8Array(buffer);
const words = new Uint16Array(buffer);
bytes[0] = 1;
bytes[1] = 2;
console.log(words[0]); // 513
console.log(buffer.byteLength, bytes.length, words.length); // 4 4 2
The two bytes we wrote through bytes are read as a single number through words: 1 + 2 × 256 = 513. Putting the low-order byte first is called little-endian, and almost every computer you use today works this way. That detail will matter in a moment.
It's a big family: Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array, and Float16Array, which arrived with ES2025. They all carry most of the familiar methods such as map, filter, slice and includes, but their length is fixed; there is no push or pop.
The answer to the opening question is right here. A Uint8Array element is a single byte and can only hold 0–255. A number that doesn't fit doesn't throw an error; it wraps around modulo 256: 256 becomes zero, 257 becomes one, and -1 becomes 255. In the console you'll see Uint8Array(4) [255, 0, 1, 255]. Its sibling Uint8ClampedArray clamps to the limit instead of wrapping: the same values become [255, 255, 255, 0]. We'll meet that sibling again in canvas pixel data.
Remember the transferable objects from the Web Workers post: an ArrayBuffer is exactly one of the types that can be transferred. When you call worker.postMessage(buffer, [buffer]), megabytes of data move to the worker without being copied.
DataView: Reading a File's Header
Typed arrays see the whole buffer as a single type. Real file formats, however, have fields of different lengths one after another in their header: a 1-byte marker here, a 4-byte number right after it. DataView lets you read any type from any position, and leaves the byte order of the number up to you.
A concrete example: let's find the width and height of a PNG file without ever drawing the image, just by looking at its first 24 bytes. PNG files start with a fixed signature, and the width and height are stored as 4-byte numbers starting at byte 16 and byte 20:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>PNG size</title>
</head>
<body>
<input type="file" id="picker" accept="image/png">
<p id="result"></p>
<script>
async function pngSize(file) {
const buffer = await file.slice(0, 24).arrayBuffer();
const view = new DataView(buffer);
const signature = [0x89, 0x50, 0x4e, 0x47]; // "\x89PNG"
for (let i = 0; i < signature.length; i++) {
if (view.getUint8(i) !== signature[i]) throw new Error("This is not a PNG");
}
return { width: view.getUint32(16), height: view.getUint32(20) };
}
document.getElementById("picker").addEventListener("change", async (event) => {
const result = document.getElementById("result");
try {
const { width, height } = await pngSize(event.target.files[0]);
result.textContent = `${width} × ${height} pixels`;
} catch (error) {
result.textContent = error.message;
}
});
</script>
</body>
</html>
Thanks to file.slice(0, 24), only 24 bytes are read even from a 20 MB image. getUint8(i) reads a single byte, and getUint32(16) turns the four bytes starting at byte 16 into one number. There's a subtle point here: PNG stores its numbers big-endian, with the high-order byte first, and that's exactly how DataView reads by default. If you're reading a little-endian format, you pass the second argument: view.getUint32(16, true). The signature check is valuable in its own right; whatever the file extension or the type field says, these first bytes tell you the real type.
Base64 and Hex: The ES2026 Shortcut
Sometimes you need to carry binary data as text: put it in a JSON field, send it in a URL, print it on screen. The two common ways are base64 and hex. ES2026 added methods to Uint8Array that do these conversions directly. Try it in the console:
const bytes = new TextEncoder().encode("Hello");
console.log(bytes.toBase64()); // "SGVsbG8="
console.log(bytes.toHex()); // "48656c6c6f"
const decoded = Uint8Array.fromBase64("SGVsbG8=");
console.log(new TextDecoder().decode(decoded)); // "Hello"
TextEncoder turns a string into UTF-8 bytes, and TextDecoder turns bytes back into a string. The bridge in between is now toBase64(), fromBase64(), toHex() and fromHex(). With toBase64({ alphabet: "base64url", omitPadding: true }) you get the URL-safe - and _ characters and the trailing = padding is dropped. Give fromBase64() invalid input and it throws a SyntaxError.
Why do these methods matter? Because the old way, btoa(), only works with Latin-1 characters. btoa("Çağrı") throws an InvalidCharacterError, since "ğ" and "ı" aren't in that range. The new way, new TextEncoder().encode("Çağrı").toBase64(), works fine and returns "w4dhxJ9yxLE=". These methods have been Baseline since September 2025 and work from Chrome 140, Firefox 133 and Safari 18.2 onward. If you also have to care about older browsers, check first with typeof Uint8Array.prototype.toBase64 === "function".
Creating and Downloading a File in the Browser
You can let the user download data you generated in the browser as a file, without going to a server at all. The recipe is always the same: create a Blob, give it a blob: address, and click a link that has a download attribute:
function downloadFile(content, fileName, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000); // release after the download starts
}
downloadFile(JSON.stringify({ name: "Ada", score: 42 }, null, 2), "data.json", "application/json");
When you run it in the console, the browser downloads a file named data.json. The download attribute tells the browser "don't open this address, save it" and sets the file name. You don't even need to add the link to the page. You can produce a CSV with the same function; adding "" (the UTF-8 BOM) to the start of the content is an old trick that helps Excel read a CSV with Turkish characters correctly.
Common Pitfalls
Don't trust file.type. It's mostly guessed from the file extension; a file with a changed extension will fool you, and for some files it comes back empty. If you really need to know the type, look at the first bytes. If the file is going to a server, repeat the check there too; the check in the browser is for user experience, not for security.
Not releasing the addresses you create with createObjectURL is a silent leak. Like the forgotten listeners we talked about in the memory management post, nobody notices, but on a page that previews hundreds of images, memory balloons. Call revokeObjectURL() once you're done.
Reading a large file in one go with text() or arrayBuffer() means loading the whole file into memory. For a video of a few hundred megabytes that's a serious load. If you only need a part, use slice(); if you need to process all of it, read it piece by piece with stream().
Overflow in typed arrays is silent. Write 300 into a Uint8Array and you won't get an error; 44 is stored. Check the range of computed values yourself before writing them, or use Uint8ClampedArray if you want them clamped to the limit.
Don't assume the byte order. DataView reads big-endian by default, while typed arrays use the machine's order, which in practice is little-endian. When reading a file format, check its documentation and always pass the second argument to DataView methods explicitly; whoever reads the code will understand what was intended.
Finally, don't mix up slice and subarray. On typed arrays, slice() copies into a new buffer, while subarray() creates a new pair of glasses looking at the same buffer. Don't be surprised when changing one also changes the other.
A Small Experiment
★☆☆ Change the file reading example: for each selected .txt file, show the number of words and lines in the file instead of the first 200 characters.
★★☆ Predict the output before running it, then try it in the console:
const a = new Uint8Array([10, 20, 30, 40]);
const b = a.subarray(1, 3);
const c = a.slice(1, 3);
b[0] = 99;
c[1] = 77;
console.log(a, b, c);
★★★ Add a file type detector to the drag-and-drop example: print the first 8 bytes of every dropped file with toHex() and, based on those bytes, say whether the file is a PNG (starts with 89504e47), a JPEG (starts with ffd8ff) or an unknown type. Then change the extension of a .png file to .jpg and try again; what does file.type say, and what does your detector say?
We can now go all the way down to the bytes of an image. In the next post we'll draw that image onto a canvas, play with shapes and animation, and touch its pixels one by one.