TypedArrays, ArrayBuffer & Buffers
Node.js Buffer did not appear out of nowhere — it is built directly on top of the standard JavaScript binary primitives that the browser also exposes: ArrayBuffer, the various TypedArray views, and DataView. Understanding how these layers fit together explains why a Buffer is a Uint8Array, why slicing can share memory, and how to move data between Node-specific and standard Web APIs without copying. Once the relationship clicks, mixed-type binary parsing and zero-copy interop become straightforward.
The three layers of binary memory
JavaScript represents raw binary data with three distinct abstractions, each sitting on top of the previous one:
| Layer | Role | Indexable? |
|---|---|---|
ArrayBuffer | A fixed-length block of raw bytes in memory. The actual storage. | No |
TypedArray (e.g. Uint8Array, Float64Array) | A typed view over an ArrayBuffer. Reads/writes elements of one type. | Yes |
DataView | A flexible view for reading/writing mixed types at arbitrary byte offsets. | Via methods |
An ArrayBuffer is just bytes — you cannot read or write it directly. You always need a view (a TypedArray or a DataView) layered over it to access the data.
const ab = new ArrayBuffer(8); // 8 raw bytes
const view = new Uint8Array(ab); // a view over those bytes
view[0] = 255;
console.log(ab.byteLength, view.length);
Output:
8 8
Buffer extends Uint8Array
In Node.js, Buffer is a subclass of Uint8Array. Every Buffer is therefore a valid TypedArray, which means any API that accepts a Uint8Array will accept a Buffer directly. The class simply adds Node-specific conveniences — encoding-aware methods like toString('utf8'), readInt32BE, write, and the various Buffer.from/Buffer.alloc factories.
import { Buffer } from 'node:buffer';
const buf = Buffer.from('hi');
console.log(buf instanceof Uint8Array); // true
console.log(buf instanceof Buffer); // true
console.log(buf[0], buf[1]); // byte access works
Output:
true
true
104 105
Because the inheritance goes one way, the reverse is not automatic: a plain Uint8Array is not a Buffer and lacks methods like readInt32BE. To “upgrade” one, wrap it (see the conversion section below).
Tip: Prefer accepting and returning
Uint8Arrayin library code that may also run in browsers or Deno. Convert toBufferonly at the boundary where you need its extra methods. This keeps your code portable.
The underlying ArrayBuffer and byteOffset
Every TypedArray (including every Buffer) exposes the ArrayBuffer it views via the .buffer property, plus .byteOffset and .byteLength. A single ArrayBuffer can back many views — this is how slicing shares memory instead of copying.
A subtle and important detail: Node pools small buffers. Buffer.from('abc') may return a view into a larger, shared ArrayBuffer, so buf.buffer.byteLength is often bigger than buf.length. Always respect byteOffset and byteLength.
import { Buffer } from 'node:buffer';
const buf = Buffer.from([10, 20, 30, 40]);
console.log('length:', buf.length);
console.log('byteOffset:', buf.byteOffset);
console.log('backing size:', buf.buffer.byteLength);
Output:
length: 4
byteOffset: 0
backing size: 4
DataView for mixed-type reads
A TypedArray interprets its whole buffer as one element type. When a binary format mixes types — say a 1-byte tag, a 4-byte big-endian integer, and an 8-byte float — DataView is the right tool. It lets you read or write any numeric type at any byte offset, with explicit endianness control.
const ab = new ArrayBuffer(13);
const dv = new DataView(ab);
dv.setUint8(0, 0x01); // tag
dv.setUint32(1, 1_000_000, false); // big-endian 32-bit int
dv.setFloat64(5, 3.14159, true); // little-endian 64-bit float
console.log('tag :', dv.getUint8(0));
console.log('int :', dv.getUint32(1, false));
console.log('float:', dv.getFloat64(5, true));
Output:
tag : 1
int : 1000000
float: 3.14159
The boolean argument is littleEndian — false (or omitted) means big-endian, the network byte order used by most wire protocols. Buffer offers the same capability through named methods (readUInt32BE, readDoubleLE, etc.), which are often more readable when you are already working with a Buffer.
Converting between Buffer and TypedArrays
Conversions fall into two categories: zero-copy (sharing the same underlying memory) and copying (independent data). Choose deliberately, because shared-memory mistakes cause hard-to-find bugs.
import { Buffer } from 'node:buffer';
// --- Zero-copy: Buffer wrapping an existing TypedArray's memory ---
const u8 = new Uint8Array([1, 2, 3, 4]);
const shared = Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength);
shared[0] = 99;
console.log('mutated source:', u8[0]); // 99 — same memory
// --- Copy: independent Buffer from a TypedArray's contents ---
const copy = Buffer.from(u8); // copies bytes
copy[1] = 0;
console.log('source intact:', u8[1]); // 2 — unaffected
// --- TypedArray view over a Buffer (zero-copy, reinterpreted) ---
const buf = Buffer.from([0, 0, 128, 63]); // 1.0 as LE float32 bytes
const floats = new Float32Array(buf.buffer, buf.byteOffset, 1);
console.log('as float32:', floats[0]);
Output:
mutated source: 99
source intact: 2
as float32: 1
The key rules:
Buffer.from(arrayBuffer, byteOffset, length)creates a view — no copy, memory is shared.Buffer.from(typedArray)(passing the array itself, not its.buffer) copies the bytes.- A
new Float32Array(buf.buffer, buf.byteOffset, count)view requires the offset to satisfy the type’s alignment; passbuf.byteOffsetexplicitly because of buffer pooling.
Warning: Never use
buf.bufferwithout also passingbuf.byteOffsetandbuf.byteLength. Pooled buffers share one largeArrayBuffer, so ignoring the offset reads neighbouring buffers’ data.
Best practices
- Treat
ArrayBufferas storage andTypedArray/DataViewas the only ways to touch it. - Use
DataView(orBuffer’sreadXxxBE/readXxxLEmethods) for mixed-type, endianness-sensitive formats; reach for a singleTypedArrayonly when every element is the same type. - Always pass
byteOffsetandbyteLengthwhen constructing a view from.buffer, because Node pools small buffers behind a sharedArrayBuffer. - Decide copy vs. zero-copy on purpose:
Buffer.from(typedArray)copies,Buffer.from(arrayBuffer, ...)shares. - Prefer
Uint8Arrayin cross-platform code and convert toBufferonly where Node-specific methods are needed. - Be explicit about endianness in
DataViewcalls — omitting the flag defaults to big-endian, which is rarely what you want forFloat/little-endian formats.