Skip to content

ArrayBuffer, Typed Arrays, and Binary Memory

advanced14 min read

When JavaScript Objects Aren't Enough

JavaScript objects are flexible: dynamic properties, prototype chains, garbage collection. But that flexibility has costs — memory overhead per object (hidden class pointers, property storage), indirection (properties accessed through hash maps or inline slots), and GC pressure (the collector must trace every object).

Sometimes you don't want any of that. For use cases that involve raw bytes — network protocols, image pixel data, audio samples, WebAssembly interop, file formats, cryptography — you need direct access to contiguous binary memory. That's what ArrayBuffer provides.

// A regular array of 1 million numbers
const jsArray = new Array(1_000_000).fill(42);
// Overhead: ~8MB+ (each element is a boxed Number or Smi, plus array overhead)

// A typed array of 1 million 32-bit integers
const typedArray = new Int32Array(1_000_000).fill(42);
// Size: exactly 4MB (1M * 4 bytes, no overhead per element)

The typed array is denser, faster for numeric computation, and interoperable with native APIs (Canvas, WebAudio, WebGL, fetch streams, WebSockets). Half the memory, none of the overhead.

ArrayBuffer: The Raw Memory Block

Mental Model

An ArrayBuffer is like a blank sheet of graph paper. Each cell holds one byte. You can't read or write the paper directly — you need a lens (a view) to interpret the bytes as numbers, characters, or other data types. The same bytes can look like different data depending on which lens you use.

// Allocate 16 bytes of zeroed memory
const buffer = new ArrayBuffer(16);

console.log(buffer.byteLength);  // 16
console.log(buffer);             // ArrayBuffer { byteLength: 16 }

// You cannot access bytes directly:
// buffer[0] → undefined (ArrayBuffer has no index access)

ArrayBuffer is opaque — you can't do anything with it directly. It's just a contiguous block of bytes. To read or write those bytes, you need a view.

Typed Arrays: Views Into Binary Data

Typed arrays are views that interpret an ArrayBuffer's bytes as a specific numeric type:

Typed ArrayBytes per ElementValue RangeUse Case
Uint8Array10 to 255Pixel channels, byte streams, file data
Uint8ClampedArray10 to 255 (clamped)Canvas pixel data (ImageData)
Int8Array1-128 to 127Signed byte data
Uint16Array20 to 65535Unicode code units, audio 16-bit PCM
Int16Array2-32768 to 32767Signed 16-bit data
Uint32Array40 to 4294967295Pixel RGBA packed, counters
Int32Array4-2^31 to 2^31-1General integer computation
Float32Array4~1.2e-38 to ~3.4e+38WebGL vertices, audio samples
Float64Array8~5e-324 to ~1.8e+308High-precision math, same as JS number
BigInt64Array8-2^63 to 2^63-1Large integer computation
BigUint64Array80 to 2^64-1Unsigned large integers

Creating Typed Arrays

// From length (allocates a new ArrayBuffer internally)
const bytes = new Uint8Array(4);  // [0, 0, 0, 0]

// From an array of values
const rgb = new Uint8Array([255, 128, 0]);  // [255, 128, 0]

// From an existing ArrayBuffer with offset and length
const buffer = new ArrayBuffer(16);
const first4 = new Int32Array(buffer, 0, 4);   // 4 Int32s starting at byte 0
const last2 = new Float64Array(buffer, 0, 2);  // 2 Float64s starting at byte 0
// Both views share the same underlying buffer!

Multiple Views, Same Memory

This is where it gets really interesting. Different views can interpret the exact same bytes differently.

const buffer = new ArrayBuffer(4);
const uint8 = new Uint8Array(buffer);
const uint32 = new Uint32Array(buffer);

uint8[0] = 0xFF;
uint8[1] = 0x00;
uint8[2] = 0x00;
uint8[3] = 0x01;

console.log(uint32[0]);  // 16777471 (0x010000FF on little-endian)
// The same 4 bytes interpreted as one Uint32 vs four Uint8s
Execution Trace
Allocate
ArrayBuffer(4): [00, 00, 00, 00]
4 bytes of zeroed memory
Uint8 view
uint8 sees: [0, 0, 0, 0] — four separate bytes
Each index is 1 byte
Write
uint8[0]=FF, uint8[1]=00, uint8[2]=00, uint8[3]=01
Bytes: [FF, 00, 00, 01]
Uint32 view
uint32[0] reads all 4 bytes as one 32-bit integer
On little-endian: 0x010000FF = 16777471
Endianness Matters

Typed arrays use the platform's native byte order (almost always little-endian on modern hardware). This means the byte at index 0 is the least significant byte. When working with network protocols or file formats that specify big-endian, use DataView instead — it lets you specify endianness per read/write operation.

DataView: Endian-Aware Access

Typed arrays are great for homogeneous data, but what about parsing a binary protocol where you need a Uint8 at offset 0, a Uint16 at offset 2, and a Float32 at offset 4? That's what DataView is for — granular, endian-explicit access to an ArrayBuffer:

const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);

// Write a big-endian 32-bit integer at byte offset 0
view.setUint32(0, 0x01020304, false);  // false = big-endian
// Bytes: [01, 02, 03, 04, 00, 00, 00, 00]

// Read it back
view.getUint32(0, false);  // 16909060 (0x01020304)
view.getUint32(0, true);   // 67305985 (0x04030201) — little-endian interpretation

// Mix different types at arbitrary offsets
view.setFloat32(4, 3.14, true);  // write a float at byte 4

Use DataView when you're parsing binary protocols (WebSocket messages, file headers, network packets) where byte order is specified by the format.

Parsing a Binary Protocol

// Example: Parse a simple message header
// Format: [type: uint8] [flags: uint8] [length: uint16-BE] [payload: bytes]
function parseMessage(buffer) {
  const view = new DataView(buffer);
  const type = view.getUint8(0);
  const flags = view.getUint8(1);
  const length = view.getUint16(2, false);  // big-endian
  const payload = new Uint8Array(buffer, 4, length);

  return { type, flags, length, payload };
}

SharedArrayBuffer: Multi-Threaded Memory

Now we're getting into serious territory. SharedArrayBuffer creates memory that can be accessed from multiple threads (Web Workers) simultaneously. Unlike regular ArrayBuffer, which is transferred (ownership moves), SharedArrayBuffer is genuinely shared.

// Main thread
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);
view[0] = 42;

// Send to worker — both threads access the same memory
worker.postMessage(shared);

// Worker
self.onmessage = (e) => {
  const view = new Int32Array(e.data);
  console.log(view[0]);  // 42 — reading main thread's data
  view[0] = 100;         // Main thread will see this change
};
Common Trap

SharedArrayBuffer requires specific security headers. After the Spectre vulnerability, browsers restrict SharedArrayBuffer to cross-origin isolated contexts. Your server must send:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Without these headers, SharedArrayBuffer is undefined. Many developers are surprised when their SAB code works locally but fails in production because the headers are missing.

Atomics: Safe Concurrent Access

Without synchronization, concurrent access to shared memory causes data races — threads overwriting each other's partial writes. The Atomics API provides atomic operations:

const shared = new SharedArrayBuffer(4);
const view = new Int32Array(shared);

// Atomic operations are indivisible — no data races
Atomics.store(view, 0, 42);           // atomic write
Atomics.load(view, 0);                // atomic read → 42
Atomics.add(view, 0, 8);              // atomic increment → 50
Atomics.compareExchange(view, 0, 50, 100);  // CAS: if 50, set to 100

// Wait/notify for thread synchronization
// Worker:
Atomics.wait(view, 0, 0);  // block until view[0] !== 0
// Main:
Atomics.notify(view, 0, 1);  // wake one waiting thread
When to use SharedArrayBuffer vs postMessage

postMessage transfers data by copying (or transferring ownership of ArrayBuffers). It's simple and safe — no race conditions. Use it for most worker communication.

SharedArrayBuffer is for cases where:

  • Multiple threads need to read/write the same data with minimal latency
  • Copying the data per message would be too expensive (large datasets, high frequency)
  • You need lock-free data structures for maximum throughput
  • You're building WebAssembly applications that share linear memory

Examples: real-time audio processing, physics simulations, image/video encoding, large dataset analysis, WASM-based applications.

The trade-off: SharedArrayBuffer is harder to reason about (data races, memory ordering, deadlocks) and requires cross-origin isolation headers. Don't use it unless copying is genuinely the bottleneck.

Memory Management for Binary Data

You might assume ArrayBuffer memory works just like any other object. It's GC-managed, yes, but with some important differences you need to know about:

  1. External memory accounting: ArrayBuffers are allocated outside V8's normal heap (in the "external memory" region). V8 tracks their size and includes it in GC decisions, but the actual bytes live outside the GC-managed heap.

  2. Transfer vs copy: When you postMessage an ArrayBuffer, you can transfer it (zero-copy, ownership moves) instead of copying:

const buffer = new ArrayBuffer(1_000_000);
// Transfer: zero-copy, but 'buffer' becomes unusable (detached)
worker.postMessage(buffer, [buffer]);
console.log(buffer.byteLength);  // 0 — detached

// Copy: buffer remains usable, but data is duplicated
worker.postMessage(buffer);  // no transfer list
console.log(buffer.byteLength);  // 1000000 — still valid
  1. Detached buffers: After transfer, the original ArrayBuffer is "detached" — byteLength becomes 0 and any views throw on access. Always check for detachment when working with transferred buffers.
What developers doWhat they should do
Using regular arrays for numeric computation
Regular arrays store boxed Numbers with per-element overhead. Typed arrays are contiguous, fixed-type memory — faster and smaller.
Use typed arrays (Float64Array, Int32Array, etc.) for numeric-heavy code
Ignoring endianness when parsing binary protocols
Typed arrays use platform-native byte order (little-endian). Most protocols are big-endian. Mismatched endianness gives wrong values.
Use DataView with explicit endianness for network/file format parsing
Using SharedArrayBuffer without Atomics
Non-atomic operations on shared memory cause data races — torn reads, lost updates, undefined behavior
Always use Atomics for concurrent read/write operations on shared memory
Forgetting that transferred ArrayBuffers become detached
Accessing a detached buffer throws TypeError. This is zero-copy transfer's trade-off.
After postMessage with transfer, the original buffer is unusable. Plan your data flow accordingly.
Quiz
What happens when two Typed Arrays share the same underlying ArrayBuffer?
Quiz
Why does SharedArrayBuffer require Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers?
Key Rules
  1. 1ArrayBuffer is raw binary memory. Typed arrays and DataView are views that interpret those bytes as specific types.
  2. 2Use typed arrays for numeric-heavy code: fixed type, contiguous memory, no per-element overhead, interoperable with Canvas/WebGL/WebAudio.
  3. 3Use DataView for binary protocol parsing where you need explicit endianness control.
  4. 4SharedArrayBuffer shares memory across threads. Always use Atomics for concurrent access to prevent data races.
  5. 5Transfer ArrayBuffers via postMessage for zero-copy performance. The original becomes detached (unusable).
  6. 6ArrayBuffer memory is GC-tracked but allocated externally. Large buffers count toward memory pressure even though they're outside the V8 heap.