OWN·VIEW·MOVE — bytes in JavaScript

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.

▦ one ArrayBuffer(16) · many views bytes moved: 0
storage — who owns the bytes view — how they're interpreted movement — copy / move / share shared — concurrent memory

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
the load-bearing idea 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.

who holds the bytes?
OWN
ArrayBuffer · SharedArrayBuffer · the Blob handle · OS pages
how are they read?
VIEW
TypedArray · DataView — type, endianness, alignment
when do bytes travel?
MOVE
copy (slice) · move (transfer) · window (subarray / BYOB)
the recurring questions Who owns the bytes (buffer vs Blob handle vs shared pages)? How are they interpreted (which view, which endianness, aligned)? When do bytes actually move (copy / move / share / merely window)? Get these straight and every API in the list is a special case.

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:

Resizable ArrayBuffer · reserve maxByteLength, commit on resize
byteLength 16 / maxByteLength 64
asymmetry worth knowing A resizable 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.

Endianness · bytes 0A 0B 0C 0D read as one uint32
← toggle read direction
the default-disagreement trap 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.
Uint8ClampedArray · why Canvas pixels saturate instead of wrapping
assign value 300
Uint8Array — wraps mod 256
44
white → black corruption
Uint8ClampedArray — clamps 0–255
255
saturates, image survives

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:

subarray (alias) vs slice (copy)

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.

▶ four ways bytes (don't) travel between agents
00 / 07
FRAME 00

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:

the allocUnsafe disclosure risk 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:

Chunk boundary · "Hi 🚀" split between two reads

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.

the object-URL leak 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.

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.

conclusion The whole surface collapses to three questions — who owns the bytes, how are they interpreted, when do they actually move — plus the fourth, who may touch them at once. The bytes are never the point. The lens is.
Back to JIT-Atlas