THE SAME BYTES / storage vs view

00 Explorable explanation · binary from first principles

Eight bytes that mean everything
and nothing at once.

A byte is just eight bits — a number from 0 to 255 with no inherent meaning. The same memory becomes a letter, an integer, a fraction of a float, or garbage, depending entirely on the lens you read it through. Below is a real ArrayBuffer of eight bytes. Scrub a cell to change the raw memory, swap the lens, and watch one block of storage produce wildly different values — computed live, in your browser.

▦ ArrayBuffer(8) · one block of memory
↕ drag a cell up/down or scroll over it to change the byte · hold ⇧ for ±16 · double-click to randomise one
Uint8Array — one unsigned byte per element
raw bytes — storage, no meaning a value — meaning you imposed corruption / ill-formed

01 The one idea everything hangs on

A byte has no meaning. You impose one.

Every API in this stack is a consequence of a single split: storage versus interpretation. An ArrayBuffer owns a contiguous block of octets and nothing else — it can't even be read directly. Everything you do to those bytes happens through a view: a TypedArray, a DataView, or Node's Buffer. None of them own bytes; they read and write someone else's.

That's the whole curriculum in one line. The byte 0x41 is the letter "A", the number 65, or a quarter of some float — the bits never move, only the lens does. Hold this distinction and every method's existence becomes obvious.

the recurring pattern the same raw bytes  →  choose a lens (type · endianness · encoding)  →  get a value. Change the lens, change the value. The bytes never moved.
storage    ArrayBuffer        owns the octets, opaque, no accessors
lens · 1   Uint8Array(buf)    → array of bytes (native endian)
lens · 2   DataView(buf)      → any type, any offset, explicit endian
lens · 3   Buffer.from(buf)   → Uint8Array subclass + encodings + I/O

02 Eight bits, two interpretations

Inside one byte — and why −1 is all ones.

Before any view, there is the byte: eight bits, each a power of two. Read those bits as unsigned and the range is 0–255. Read the very same bits as signed (two's complement) and the top bit flips meaning — it now subtracts 128. That's why 0xFF is 255 through one lens and −1 through another. Click the bits.

unsigned (Uint8)0
signed · two's complement (Int8)0
hex0x00
two's complement, by hand −1 in 8 bits is 1111 1111: take +1 = 0000 0001, invert to 1111 1110, add one → 1111 1111. Set every bit above and watch the signed lens read −1 while the unsigned lens reads 255. Same storage; the sign is a choice.

03 The cast, by layer

Each layer adds exactly one capability.

From the OS memory page up to a JavaScript value, every layer either stores bytes or interprets them — never both. The crucial fact: Buffer extends Uint8Array, so everything true of a TypedArray is already true of a Buffer. Click any layer for the Node internals and the spec behind it.

⌗ the lens stack · storage ↔ meaning
⤢ tap to inspect

04 The single most illuminating exercise

Two views over one buffer. Write through one, read through the other.

Lay a Uint8Array and a Uint16Array over the same four bytes. Edit a byte below; the uint8 row and the uint16 row update together because they alias the same memory. The uint16 values also reveal your platform's native endianness — flip the order toggle to see the bytes recombine the other way.

⇉ Uint8Array & Uint16Array over one ArrayBuffer(4)
⤢ tap to inspect
the limitation that births DataView A bare Uint16Array always uses the platform's byte order — almost always little-endian. If you didn't produce the bytes, you can't trust a multi-byte TypedArray read of them. That's exactly the gap DataView closes.

05 The precision tool

Parse a binary header — one field at a time, endianness named on every read.

Real formats mix types at fixed offsets with a specified byte order: a one-byte magic, a big-endian uint32 length, little-endian flags, a float64. DataView is built for exactly this — you state the offset and the endianness on every call. Step the cursor through an 8-byte header and watch each field get read.

▷ DataView · parse cursor
⤢ tap to inspect
0 / 4
READ 00 · —

Press play to walk the cursor

Each read names its type, offset, and byte order explicitly — the whole point of DataView.

06 Same bytes, different number

Endianness is just another lens — and a silent one.

Reading a multi-byte integer requires knowing the byte order. DataView forces you to state it; readUInt32LE/BE name it; a plain Uint32Array quietly uses the platform's. Networks and many file formats are big-endian, so guessing wrong gives a plausible-looking, totally wrong number. Flip the switch.

⇄ 4 bytes → uint32
⤢ tap to inspect

07 Where small Buffers actually live

The Buffer pool — and why allocUnsafe is a footgun.

Node carves small buffers out of a shared ~8 KiB ArrayBuffer as slabs, so each isn't a separate allocation. The consequences: a small Buffer's byteOffset is rarely zero, its .buffer is bigger than the Buffer itself, and allocUnsafe hands you whatever stale bytes were there before. Click a box.

▤ allocation model · pool vs standalone
⤢ tap to inspect

08 Where storage-vs-view bites hardest

Why a stream splits the € in two.

Put two facts together — a UTF-8 character can span up to four bytes, and a stream emits chunks sized by byte count with no regard for characters — and a single character gets sliced across a chunk boundary. Decode each chunk on its own and you get � mojibake. Press play to trace the string "café €99" through the seam; click any node for the Node internals and the Unicode rule behind it.

▶ café €99 · value → chunks → decode
⤢ tap to inspect
00 / 12
FRAME 00 · —

Press play to trace the bytes

Amber-green lanes are raw bytes — pure storage. Blue lanes are decoded text — meaning you impose. Red is corruption.

09 The fix · stateful decoding

Emit complete characters, keep the tail.

node:string_decoder exists for exactly this. decoder.write(chunk) returns only the characters it can complete and retains any incomplete trailing bytes internally until the next call. readable.setEncoding('utf8') wires one into the stream for you. Each card opens the same deep-dive the trace nodes do.

const { StringDecoder } = require('node:string_decoder');
const d = new StringDecoder('utf8');
d.write(chunk1); // → "café "  (retains the dangling E2)
d.write(chunk2); // → "€99"    (prepends E2, completes E2 82 AC)
d.end();         // → ""       (nothing buffered, clean)

10 The master table, made tactile

Does this method copy the bytes, or alias them?

Most binary bugs are one wrong assumption in this table. Pick a method, then mutate buffer B. If B is a view over A's memory, the change bleeds back into A; if it's a copy, A is untouched. Watch which one happens.

buffer A (source)
buffer B
Pick a method above.

11 The reference, made executable

The whole API surface — as a console you operate.

A reference table tells you a method's signature. This runs it. Pick a layer, click any method, and a live REPL cell executes it against a real shared buffer — showing the actual return value and, crucially, whether the bytes were copied, aliased, moved, or written in place. The memory strip at the bottom is real: watch A and B diverge or move together.

binary-data — node › ArrayBuffer
copy shared / view move · detach in place
why a console, not a table Signatures are inert; behaviour is what bites. The same verb — slice — copies on an ArrayBuffer and a TypedArray but historically aliases on a Buffer. Running each one against shared memory turns the copy-vs-share table from something you memorise into something you've seen happen.

12 The footguns, gathered

Almost all of them are one confusion: storage mistaken for meaning.

FootgunWhat goes wrong
per-chunk toString()multi-byte char split across chunks → �. Use StringDecoder / setEncoding.
allocUnsafe leakuninitialised pool memory exposes old data. Use alloc unless overwriting fully.
subarray / slice aliasinga shared view mutates the original. Uint8Array.slice() copies; subarray doesn't.
str.length as bytesUTF-16 code units, not bytes. Use Buffer.byteLength(str, enc).
wrong endiannessa valid-looking but wrong integer. State LE/BE explicitly.
reading a detached bufferafter transfer()/postMessage the bytes moved; access throws or reads zero.
BOM & normalizationa leading U+FEFF or NFC/NFD form changes length & equality unexpectedly.
conclusion Bytes are not text you happen to have — they are storage waiting for a lens. Choose the lens deliberately, state its endianness, and keep its state across the seams.
Back to JIT-Atlas