00 A byte has no meaning
Sixteen bytes. Pick a lens.
The whole binary ecosystem is one idea borrowed from C: separate owning bytes from interpreting bytes. Below are 16 real bytes in one ArrayBuffer. Click a view — the values change, the bytes never do.
01 The founding split
JavaScript had no byte. So it copied C's oldest trick.
A JS number is an IEEE-754 double. The language shipped with no integer type, no byte, no way to even name a run of raw memory — fine until WebGL needed to push vertex arrays to the GPU without boxing every element as a heap object. The fix, later generalised in ES2015, was C's separation of owning bytes from interpreting bytes.
// C — own opaque bytes, then cast a typed pointer over them void* p = malloc(16); // own 16 opaque bytes int32_t* ip = (int32_t*)p; // interpret as four 32-bit ints uint8_t* bp = (uint8_t*)p; // ...or sixteen bytes — same memory // JavaScript — identical shape const buf = new ArrayBuffer(16); // own 16 opaque bytes — unreadable const i32 = new Int32Array(buf); // interpret as Int32[4] const u8 = new Uint8Array(buf); // alias the SAME memory as Uint8[16] i32[0] = 1; // u8[0..3] now reflect that write
ArrayBuffer is malloc's return value — a handle to bytes you cannot touch. A view is the typed pointer plus bounds. Two views over one buffer are type punning: a write through one is visible through the other, because there is no copy. Hold onto "no copy unless something says copy."
02 The spine
Every API answers one of four questions.
Sixty-odd objects collapse into four axes. Once you can place an API on these, its behaviour is predictable rather than memorised.
03 Storage buffers · the raw memory
Fixed, shared, or growable — three ways to own bytes.
ArrayBuffer is fixed-length, contiguous, opaque. The interesting behaviour is detachment: list it in postMessage's transfer array and it's neutered in the sender — byteLength → 0, every view throws. That's std::move. Omit the transfer list and you get a deep copy instead.
SharedArrayBuffer is the one case where bytes are neither copied nor moved but genuinely shared — the same pages in two agents, like mmap(MAP_SHARED). Resizable buffers commit pages on demand up to a reserved maxByteLength. Drag the slider:
ArrayBuffer can grow and shrink. A SharedArrayBuffer can only grow() — monotonically — because shrinking memory out from under another mid-read thread is unsound. maxByteLength reserves virtual address space; pages commit as you resize, exactly like mmap reserving a range and faulting pages in.
04 Views · interpreting the memory
The same four bytes, two readers, two different numbers.
There are 12 typed-array types today (the classic 11 plus Float16Array). Three details dominate correctness: alignment, endianness, and the clamping of Uint8ClampedArray. The endianness trap is the sharp one — the two view families disagree by default.
new Uint32Array(buf)[0] reads in the platform's native order (little-endian on every common machine). new DataView(buf).getUint32(0) defaults to big-endian. Identical bytes, different numbers. For any externally-defined format, use DataView with endianness stated explicitly — it's JS's ntohl/htonl.
Window vs copy — the aliasing footgun
subarray() returns a view aliasing the same buffer — a window, no copy. slice() copies into fresh memory. Confusing them is the classic "why did my other array mutate?" bug. Press the buttons:
05 Movement · when do bytes actually travel?
Copy, move, share, or merely window. Step through it.
The same postMessage is a deep copy or a zero-cost move depending purely on the transfer list. A SharedArrayBuffer is neither — it's shared pages. And subarray/BYOB moves no bytes at all, only a window. Play the sequence — watch which memory actually changes.
06 High-level containers
Blob is not memory. It's a handle.
The surprise for buffer people: a Blob is not addressable bytes. It's an immutable handle to a sequence of bytes plus a MIME type — and a large Blob is often backed by a temp file on disk, materialised lazily. You can't index it; you extract bytes asynchronously. Think file descriptor with a content type, not memory region. File is just Blob + name + lastModified.
Node's Buffer is a Uint8Array subclass, so every view rule applies — but it folds in DataView + TextEncoder duties because it predates stable TypedArrays. The systems gotcha is pooling. Click each card:
Buffer.allocUnsafe draws from a shared 8 KiB pool and skips zero-filling — it can hand you stale heap contents from unrelated allocations. A small Buffer's .buffer is usually larger than the Buffer and shared, so respect byteOffset/byteLength and never hand buf.buffer to something raw. Buffer.alloc zero-fills and is the safe default.
07 Text encoding & decoding
The border between UTF-16 strings and the byte world.
JS strings are UTF-16 code units internally, so these APIs are the crossing. TextEncoder emits UTF-8 only; its encodeInto(str, view) writes into a caller buffer to dodge an allocation. TextDecoder is the rich side — dozens of legacy encodings, fatal mode, and crucially streaming.
UTF-8 is variable width (1–4 bytes per code point), so a chunk boundary can fall mid-character. The streaming decoder retains the dangling bytes until the next chunk completes the sequence — the fix for the classic mojibake boundary bug. Watch a 🚀 (4 bytes) get split:
08 I/O & streaming
Backpressure: the consumer sets the pace.
The deepest principle in the Streams API is backpressure. A ReadableStream doesn't shove data at you — the consumer pulls and signals readiness, so a 4 GB download flows through fixed-size chunks without ever sitting whole in RAM. The producer is throttled to the consumer's rate.
The detail worth knowing is the BYOB reader: you hand the stream a pre-allocated buffer and it fills it in place — that's read(fd, buf, n) exactly, a zero-copy read into caller-owned memory rather than allocating a fresh chunk each pull. fetch exposes binary four ways: arrayBuffer(), blob(), bytes(), and .body as a stream.
URL.createObjectURL(blob) mints a blob: URL pointing at in-memory (or disk-backed) bytes for <img>/<video>. It pins the Blob alive until revokeObjectURL — forgetting to revoke is a genuine leak, like an open handle you must close. Contrast data: URLs, which inline base64 at a ~33% size cost and need no revoke.
09 Threads, crypto & the GPU boundary
Atomics is C11 futexes, in JavaScript.
Plain reads/writes to a SharedArrayBuffer are subject to a memory model lifted from C++11: concurrent non-atomic access is a data race and tearing of multi-byte values is permitted. Atomics is the synchronisation layer — load/store/add/exchange, compareExchange (CAS, the lock-free primitive), and wait/notify, which is a futex and forbidden on the main thread. Atomic ops are sequentially consistent and establish happens-before edges — that fence is what makes a non-atomic write on one thread reliably visible on another.
SharedArrayBuffer after Spectre, then re-gating it behind COOP + COEP isolation headers, was precisely to deny that timer to untrusted cross-origin code.
Web Crypto (crypto.subtle) is async and trades in ArrayBuffers — native rather than JS for constant-time execution and real OS entropy. Canvas / WebGL / WebGPU are all about moving bulk numeric data across the JS↔GPU boundary without per-element boxing: ImageData.data is a Uint8ClampedArray (now the clamping makes sense), WebGPU.mapAsync hands back an ArrayBuffer staged against mapped device memory.
10 The full callable surface
Every method, filterable. Type to find one.
The complete API surface for the six groups, grouped by object. Filter by name or by axis. Click any card for the signature and the gotcha.