Skip to content
Node.js nd buffers 4 min read

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:

LayerRoleIndexable?
ArrayBufferA 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
DataViewA 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 Uint8Array in library code that may also run in browsers or Deno. Convert to Buffer only 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 littleEndianfalse (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; pass buf.byteOffset explicitly because of buffer pooling.

Warning: Never use buf.buffer without also passing buf.byteOffset and buf.byteLength. Pooled buffers share one large ArrayBuffer, so ignoring the offset reads neighbouring buffers’ data.

Best practices

  • Treat ArrayBuffer as storage and TypedArray/DataView as the only ways to touch it.
  • Use DataView (or Buffer’s readXxxBE/readXxxLE methods) for mixed-type, endianness-sensitive formats; reach for a single TypedArray only when every element is the same type.
  • Always pass byteOffset and byteLength when constructing a view from .buffer, because Node pools small buffers behind a shared ArrayBuffer.
  • Decide copy vs. zero-copy on purpose: Buffer.from(typedArray) copies, Buffer.from(arrayBuffer, ...) shares.
  • Prefer Uint8Array in cross-platform code and convert to Buffer only where Node-specific methods are needed.
  • Be explicit about endianness in DataView calls — omitting the flag defaults to big-endian, which is rarely what you want for Float/little-endian formats.
Last updated June 14, 2026
Was this helpful?