Typed arrays and ArrayBuffer

Typed arrays read compact binary data through ArrayBuffer; understand views, byte order, copies, and resizing to parse protocols and files correctly.

level intermediate time 9 min at Standard depth
version Node 24
what

An ArrayBuffer holds bytes, a typed array interprets them as one fixed numeric type, and a DataView reads and writes fields at chosen offsets and byte orders.

trap

A view doesn’t necessarily own its data: subarray(), views built from a buffer, and other views may all point to the same bytes, while multibyte typed arrays use the runtime’s native byte order.

fix

State whether an API accepts a buffer, a view, or a copy; when parsing an external format, preserve the exact byteOffset and byteLength and use DataView with an explicit byte order.

What it is and why it exists

An ArrayBuffer represents binary storage measured in bytes, but it has no indexing operation for reading a number. A typed array is a numeric view over that storage: Uint8Array interprets each element as one unsigned byte, while Float32Array interprets each four bytes as a binary32 floating-point value. DataView is also a view, but each call chooses the field type, byte offset, and endianness .

A regular JavaScript array can mix arbitrary values, change length, and contain holes. Every typed-array index represents a numeric element, writes convert to the destination type, and the view can’t grow with push(). Files, network frames, Canvas pixels, audio samples, WebGL attributes, and WebAssembly linear memory all pass bytes through these interfaces.

new ArrayBuffer(n) creates a fixed-length buffer. Node 24 also supports resizable buffers: supply maxByteLength at construction and the owner can call resize() within that limit. The old rule that every ArrayBuffer has an immutable size is therefore wrong; resizability belongs to the particular buffer.

Use a typed array for a run of values with one element type. Use DataView for mixed fields or an external format with a specified byte order. They often work together: a DataView parses the header, then Uint8Array.subarray() exposes a payload window. Start from the data format and choose a view, instead of treating a constructor name as a format specification.

How it works

The buffer owns the bytes; a view describes how to access a region. Several views can overlap, and a write through any one of them is visible when another reads the shared bytes.

Bytes in a new buffer are initialized to zero. Every view has buffer, byteOffset, and byteLength; a typed array also exposes its element-counted length and the constructor’s BYTES_PER_ELEMENT. byteOffset and byteLength are always byte counts, while length counts elements.

Indexed typed-array writes perform numeric conversion. Non-clamped unsigned integer types reduce values to their bit width, Uint8ClampedArray limits results to 0 through 255, and floating types round to a representable value. BigInt64Array and BigUint64Array take bigint values; writing an ordinary number directly is an error.

Multibyte typed arrays interpret data in the runtime’s native byte order, with no parameter to switch it. Multibyte DataView methods take a littleEndian argument: omitted or false means big-endian, while true means little-endian. When an external format defines byte order, passing the argument on every call leaves that protocol decision visible in the code.

Constructor shape determines ownership

The constructor arguments determine whether an operation allocates, copies, or shares. Pay particular attention to length versus byteLength, and to value conversion versus reinterpretation of raw bytes.

ExpressionNew bufferLength unitResult
new Uint16Array(4)Yes4 elementsAllocates 8 zero bytes
new Uint16Array([1, 2])YesInput elementsConverts and copies two values
new Uint16Array(otherView)YesInput elementsConverts and copies element by element
new Uint16Array(buffer, offset, length)NoByte offset, element lengthCreates a view over the original buffer
new DataView(buffer, offset, byteLength)NoByte offset and byte lengthCreates a general view over the original buffer

Parse binary input in a stable order:

  1. State whether the function accepts an ArrayBuffer or a particular view, then validate that type at the boundary.
  2. Limit access with the input view’s own byteOffset and byteLength.
  3. Check the remaining byte count before reading each field or payload.
  4. Read the signedness, width, and byte order required by the format, then validate the business range.

Examples

These four examples build from shared bytes to offset packet parsing, copy-versus-view behavior, and resizable buffers. The shown output came from running each file locally with Node 24.14.0.

Put two views over one buffer

The DataView writes a two-byte header in big-endian order, then the Uint8Array writes the payload. Neither object copies data from the other.

shared_views.js
const buffer = new ArrayBuffer(6);
const bytes = new Uint8Array(buffer);
const fields = new DataView(buffer);

fields.setUint16(0, 0x1234, false);
bytes.set([79, 75, 33, 0], 2);

console.log([...bytes]);
console.log(fields.getUint16(0, false).toString(16));
console.log(new TextDecoder().decode(bytes.subarray(2, 5)));
[ 18, 52, 79, 75, 33, 0 ]
1234
OK!

The byte view shows 0x12 and 0x34 in that order because the write explicitly chose big-endian. The end position in subarray(2, 5) is exclusive, so the decoded range contains exactly three payload bytes.

Writing the header through a Uint16Array would instead make the byte order native. That may be the contract for data consumed only within one process, but it is too implicit for a file or network frame.

Parse a packet with a nonzero offset

Real input is often a window into a larger receive buffer. A parser must restrict its DataView to that window instead of starting at index 0 of the backing buffer.

parse_packet.js
function parsePacket(bytes) {
  if (!(bytes instanceof Uint8Array)) {
    throw new TypeError('Expected Uint8Array');
  }

  const headerBytes = 8;
  if (bytes.byteLength < headerBytes) {
    throw new RangeError('Truncated header');
  }

  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const payloadLength = view.getUint16(2, false);
  if (headerBytes + payloadLength !== bytes.byteLength) {
    throw new RangeError('Invalid payload length');
  }

  return {
    version: view.getUint8(0),
    flags: view.getUint8(1),
    sequence: view.getUint32(4, false),
    payload: new TextDecoder().decode(bytes.subarray(headerBytes)),
  };
}

const storage = new Uint8Array([
  99, 99, 1, 5, 0, 3, 0, 0, 0, 42, 79, 75, 33, 88,
]);
const packet = storage.subarray(2, 13);
console.log(JSON.stringify(parsePacket(packet)));
{"version":1,"flags":5,"sequence":42,"payload":"OK!"}

storage has a byte outside the packet at each end, exposing an implementation that ignores byteOffset. The length check runs before payload decoding, so a corrupt length field cannot turn into an out-of-bounds read.

This example requires the packet to fill the input view exactly. A protocol that allows trailing bytes can replace the equality check with an upper-bound check, but that choice must come from its format specification rather than parser guesswork.

Compare subarray() with slice()

subarray() creates a shared window, while slice() creates an independent copy. Writes to the source or shared window are mutually visible; the earlier copy stays unchanged.

copy_or_view.js
const source = new Uint8Array([10, 20, 30, 40, 50]);
const windowView = source.subarray(1, 4);
const copy = source.slice(1, 4);

source[2] = 99;
windowView[0] = 77;

console.log([...source]);
console.log([...windowView]);
console.log([...copy]);
console.log(windowView.buffer === source.buffer);
console.log(copy.buffer === source.buffer);
[ 10, 77, 99, 40, 50 ]
[ 77, 99, 40 ]
[ 20, 30, 40 ]
true
false

Sharing isn’t necessarily a bug. A synchronous parser can use windows without pointless copies; when data is cached, passed to an uncontrolled caller, or retained across an asynchronous boundary, an independent copy usually gives clearer ownership.

buffer.slice() copies bytes too, but its indices are relative to the whole ArrayBuffer. When you hold only a local view, view.slice() is less likely to copy data outside that window by accident.

Watch a length-tracking view

When the typed-array constructor omits length, a view over a resizable buffer tracks the available length. A view with an explicit length doesn’t grow with the buffer, and it becomes temporarily out of bounds if shrinking crosses its end.

resizable_buffer.js
const buffer = new ArrayBuffer(4, { maxByteLength: 8 });
const tracking = new Uint8Array(buffer);
const fixed = new Uint8Array(buffer, 0, 4);

tracking.set([1, 2, 3, 4]);
buffer.resize(6);
tracking.set([5, 6], 4);

console.log(tracking.length, [...tracking]);
console.log(fixed.length, [...fixed]);

buffer.resize(2);
console.log(tracking.length, [...tracking]);
console.log(fixed.length, fixed[0]);
6 [ 1, 2, 3, 4, 5, 6 ]
4 [ 1, 2, 3, 4 ]
2 [ 1, 2 ]
0 undefined

Growth supplies zero-initialized bytes, which the example then changes to 5 and 6. After the buffer shrinks to two bytes, the fixed view reports length 0; it does not become a new two-element window truncated to what remains.

A resizable buffer suits an owner that genuinely needs growth, but that doesn’t mean every API should expose a mutable length. Pass a fixed-length view or copy at the boundary when a consumer relies on a stable size.

Pitfalls

Treating element counts as byte counts

Fix: suffix layout constants with Bytes or Elements, and convert with Type.BYTES_PER_ELEMENT. Validate the offset, element count, and buffer boundary before constructing the view.

Ignoring the input view’s offset

Fix: preserve the window geometry with new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength). Test fixtures should place sentinel bytes before and after the target instead of always starting at buffer index 0.

Treating a shared window as a copy

Fix: state in the API name or documentation whether the result is a borrowed view or an owned copy. Use slice() when isolation is required; keep subarray() when sharing is deliberate, and constrain who may write and when.

Parsing specified byte order with a typed array

Fix: use DataView for external multibyte fields and explicitly pass true or false on every read and write. Assert numbers from fixed byte fixtures rather than relying only on encode-then-decode round trips through the same implementation.

Expecting writes to validate the range

Fix: validate Number.isFinite(), integer requirements, and the business range before assignment. Rely on typed-array conversion only when the format itself calls for modular or saturating conversion.

Confusing value conversion with byte reinterpretation

Fix: first state whether the target operation is value conversion, byte copy, or byte reinterpretation. For reinterpretation, construct from view.buffer, view.byteOffset, and view.byteLength; for conversion, pass the original view itself.

Reusing a view after its buffer changes

Fix: give transfer or resizing one owner and stop using old views after that operation. Test invalidation across worker or asynchronous boundaries; an object still being in scope doesn’t prove that its storage remains accessible.

Deep Element types and numeric conversion

Element types and numeric conversion

JavaScript has no general TypedArray constructor for application code; choose a concrete constructor. Node 24 provides the following 12 types. Each has a fixed element width, and the destination type controls write behavior.

ConstructorBytes per elementNumeric categoryWrite behavior
Int8Array1numberSigned 8-bit integer conversion
Uint8Array1numberUnsigned 8-bit integer conversion
Uint8ClampedArray1numberClamps to 0 through 255, then rounds
Int16Array2numberSigned 16-bit integer conversion
Uint16Array2numberUnsigned 16-bit integer conversion
Int32Array4numberSigned 32-bit integer conversion
Uint32Array4numberUnsigned 32-bit integer conversion
Float16Array2numberRounds to IEEE 754 binary16
Float32Array4numberRounds to IEEE 754 binary32
Float64Array8numberStores IEEE 754 binary64
BigInt64Array8bigintSigned 64-bit integer conversion
BigUint64Array8bigintUnsigned 64-bit integer conversion

Integer conversion is not business validation. A color channel may need the saturation behavior of Uint8ClampedArray, while an out-of-range protocol version should usually be rejected. Choose a type together with an invalid-input policy, not from the binary layout alone.

Floating arrays don’t store arbitrary-precision decimal numbers. Float16Array and Float32Array round earlier than ordinary JavaScript numbers, so equality checks should use a representable format value or an allowed error. Currency minor units and identifiers that must remain exact shouldn’t be changed to floating point just to save bytes.

Copying, sharing, and reinterpretation

These APIs look similar, but their ownership and conversion differ. Use this matrix during review instead of inferring behavior from variable names.

OperationNew bufferShares writes with inputMeaning
view.subarray(start, end)NoYesCreates a same-type window
view.slice(start, end)YesNoCopies selected elements
new SameType(view)YesNoCopies element values
new OtherType(view)YesNoConverts and copies element values
new OtherType(view.buffer, offset, length)NoYesReinterprets shared bytes as another type
view.buffer.slice(start, end)YesNoCopies bytes in backing-buffer coordinates

The last row can escape a local view because start and end are relative to the backing buffer. To copy the raw bytes covered by any typed array, first construct new Uint8Array(view.buffer, view.byteOffset, view.byteLength), then call slice() on that byte view.

When shared bytes are reinterpreted as a different element type, the offset must satisfy the new type’s alignment requirement. For example, new Uint32Array(buffer, 1) throws RangeError because 1 isn’t a multiple of 4. DataView has no such alignment restriction, so an unaligned format field doesn’t require manual byte assembly.

Resizable and transferable buffers

Only an ArrayBuffer constructed with maxByteLength is resizable, as reported by resizable. resize(newLength) can’t exceed that maximum; growth adds zero-initialized bytes and shrinking discards trailing bytes. A typed-array view with no explicit length tracks the remaining buffer, while an explicit-length view keeps its declared range.

When a fixed-length view’s range extends beyond a shrunken buffer, the whole view becomes out of bounds; it doesn’t preserve the leading elements that still fit. The view can come back in bounds if the buffer later grows far enough, but discarded bytes don’t return. A consumer that needs stable content should copy before resizing or prevent the owner from resizing concurrently.

buffer.transfer() creates a destination buffer and detaches the source; transferToFixedLength() makes a non-resizable destination. Structured-clone transfer in browsers and Node.js also detaches the sender’s buffer. Transfer is useful for an explicit ownership handoff, but the old buffer and all its old views must be treated as consumed.

A SharedArrayBuffer isn’t transferred this way; it lets multiple execution contexts see the same storage. Views still provide only an access shape, not synchronization. Coordination belongs to Atomics and a higher-level protocol, and shouldn’t be inferred from ordinary ArrayBuffer examples.

Bounds, alignment, and out-of-range access

A typed-array constructor requires byteOffset to be a multiple of the element width and the selected element range to fit the buffer. DataView permits any byte offset because its access methods can read unaligned fields. Their out-of-range reads differ too: typed-array indexed access returns undefined, while a DataView field read throws RangeError.

That difference doesn’t replace explicit length checks. Exception-driven parsing mixes malformed input with programming errors, and code may consume fields read before the throw. A parser should prove that each complete field exists before reading it, then validate its value.

Node.js Buffer is a Uint8Array subclass, but it often represents a local window in a larger allocation. Passing only buffer.buffer to a browser-style helper can expose bytes outside that window. Preserve its offset and length together, or copy the required window at the API boundary.

Ownership and API contracts

A parameter named only data hides the most important facts of a binary API. Its signature and documentation should at least state whether it accepts an ArrayBuffer, any ArrayBufferView, or a concrete Uint8Array; whether it reads the whole window; and whether it retains a reference during or after the call.

A borrowed view suits synchronous read-only work, with the caller retaining storage ownership afterward. If the callee caches the data, defers work, or hands it to other code, copying establishes independent ownership; for a cross-thread handoff of a large buffer, transfer can express ownership movement. The lifecycle determines the choice, without a made-up universal performance multiplier.

Return values need the same policy. Names such as viewPayload() and copyPayload(), or equally explicit documentation, prevent more mistakes than a generic getPayload(). Tests should verify identity and mutation propagation alongside byte snapshots.

Describe the layout before accessing bytes

Put each field’s offset, width, signedness, and byte order in a format description or named constants instead of scattering bare numbers across reads. Derive offsets from the end of the preceding field. A review can then see whether later fields moved with a format change.

A versioned format should read the smallest prefix shared by every version, validate the version, and only then enter its layout. An older parser should reject an unknown version explicitly instead of continuing with its current layout and producing plausible-looking values by accident.

Compare a variable field’s length with the bytes remaining in the current view before creating a subarray(). If the length also drives allocation, validation against the field’s representable width isn’t enough; the API needs a separate limit based on its business contract.

Text payloads add an encoding-error policy. TextDecoder replaces malformed bytes by default; when a protocol requires strict UTF-8, construct it with { fatal: true } and treat decoding failure as an invalid message.

Testing binary parsers

An encoder and parser can share the same wrong assumption, so their round trip still passes. Keep at least one fixed byte fixture from the format specification or another implementation, then assert field values and byte order directly.

  1. Truncate input at every field boundary, including empty input and one byte short.
  2. Repeat success and failure cases with a view whose byteOffset is not zero.
  3. Cover signed minima, maxima, zero, and representable values outside the business range.
  4. Verify byte order with fixed big-endian and little-endian fixtures; a self round trip is only a supplement.
  5. Mutate the source and returned view, then test references retained across resizing or transfer.

Error messages should distinguish insufficient bytes, a mismatched declared length, and an invalid field value. That lets callers decide whether to await more stream data, reject a corrupt message, or report an unsupported version. It also makes a generated parser’s missing boundary check easier to locate in tests.

Further reading

checkpoint

4 questions · 2 predict-the-output · 1 spot-the-bug

next up File api soon Canvas soon Web workers soon Webassembly soon
Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?