ArrayBuffer, Typed Arrays, and Binary Memory
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
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 Array | Bytes per Element | Value Range | Use Case |
|---|---|---|---|
Uint8Array | 1 | 0 to 255 | Pixel channels, byte streams, file data |
Uint8ClampedArray | 1 | 0 to 255 (clamped) | Canvas pixel data (ImageData) |
Int8Array | 1 | -128 to 127 | Signed byte data |
Uint16Array | 2 | 0 to 65535 | Unicode code units, audio 16-bit PCM |
Int16Array | 2 | -32768 to 32767 | Signed 16-bit data |
Uint32Array | 4 | 0 to 4294967295 | Pixel RGBA packed, counters |
Int32Array | 4 | -2^31 to 2^31-1 | General integer computation |
Float32Array | 4 | ~1.2e-38 to ~3.4e+38 | WebGL vertices, audio samples |
Float64Array | 8 | ~5e-324 to ~1.8e+308 | High-precision math, same as JS number |
BigInt64Array | 8 | -2^63 to 2^63-1 | Large integer computation |
BigUint64Array | 8 | 0 to 2^64-1 | Unsigned 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
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
};
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:
-
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.
-
Transfer vs copy: When you
postMessagean 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
- Detached buffers: After transfer, the original ArrayBuffer is "detached" —
byteLengthbecomes 0 and any views throw on access. Always check for detachment when working with transferred buffers.
| What developers do | What 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. |
- 1ArrayBuffer is raw binary memory. Typed arrays and DataView are views that interpret those bytes as specific types.
- 2Use typed arrays for numeric-heavy code: fixed type, contiguous memory, no per-element overhead, interoperable with Canvas/WebGL/WebAudio.
- 3Use DataView for binary protocol parsing where you need explicit endianness control.
- 4SharedArrayBuffer shares memory across threads. Always use Atomics for concurrent access to prevent data races.
- 5Transfer ArrayBuffers via postMessage for zero-copy performance. The original becomes detached (unusable).
- 6ArrayBuffer memory is GC-tracked but allocated externally. Large buffers count toward memory pressure even though they're outside the V8 heap.