JavaScript ArrayBuffer and TypedArrays

JavaScript wasn't originally designed for binary data manipulation. For years, developers worked exclusively with strings and objects, encoding binary data as Base64 when necessary. This changed with...

Key Insights

  • ArrayBuffer provides a raw binary data buffer that cannot be directly manipulated—you must use TypedArray views or DataView to read and write data
  • TypedArrays offer optimized, typed access to binary data with performance that can be 2-10x faster than regular arrays for numeric operations
  • Understanding endianness and byte alignment is critical when working with binary protocols, file formats, and cross-platform data exchange

Introduction to Binary Data in JavaScript

JavaScript wasn’t originally designed for binary data manipulation. For years, developers worked exclusively with strings and objects, encoding binary data as Base64 when necessary. This changed with the introduction of ArrayBuffer and TypedArrays, which brought true binary data handling to the language.

Modern web applications demand efficient binary data processing. WebGL requires vertex and texture data in specific binary formats. File APIs need to read and write binary files. WebSockets often use binary protocols for efficiency. Audio processing with the Web Audio API manipulates raw audio samples. Video encoding, image manipulation, cryptographic operations—all benefit from direct binary data access.

The difference between regular arrays and binary data structures is fundamental:

// Regular array - each element is a full JavaScript value
const regularArray = [1, 2, 3, 4];
// Each number is stored as a 64-bit floating point value
// Total memory: ~256 bytes (includes object overhead)

// TypedArray - compact binary representation
const typedArray = new Uint8Array([1, 2, 3, 4]);
// Each number is exactly 1 byte
// Total memory: 4 bytes + minimal overhead

This isn’t just about memory savings. Binary data structures enable interoperability with systems that expect specific byte layouts, and they unlock performance optimizations impossible with regular arrays.

Understanding ArrayBuffer

An ArrayBuffer is a fixed-length container for raw binary data. Think of it as a chunk of memory allocated in JavaScript’s heap. The key characteristic: you cannot directly read from or write to an ArrayBuffer. It’s just storage.

// Create a 16-byte buffer
const buffer = new ArrayBuffer(16);

console.log(buffer.byteLength); // 16

// This doesn't work - no direct access
// buffer[0] = 255; // Error!

// ArrayBuffers are fixed-size
// You cannot resize them after creation

ArrayBuffers are intentionally low-level. This design separates storage from interpretation. The same bytes can represent different things depending on how you view them—eight 16-bit integers, sixteen 8-bit integers, four 32-bit floats, or a mix of types.

You can create ArrayBuffers of any size, though practical limits exist. Modern browsers can handle buffers of several gigabytes, but you’ll hit memory constraints eventually:

// Small buffer for a simple protocol header
const header = new ArrayBuffer(8);

// Larger buffer for image data (1920x1080 RGBA)
const imageBuffer = new ArrayBuffer(1920 * 1080 * 4);

// Check available memory before allocating huge buffers
try {
  const huge = new ArrayBuffer(1024 * 1024 * 1024); // 1GB
} catch (e) {
  console.error('Out of memory:', e);
}

TypedArray Views

TypedArrays are views that provide typed access to an ArrayBuffer’s data. Each TypedArray type interprets the underlying bytes according to a specific numeric format:

  • Int8Array / Uint8Array: 8-bit signed/unsigned integers (-128 to 127 / 0 to 255)
  • Int16Array / Uint16Array: 16-bit signed/unsigned integers
  • Int32Array / Uint32Array: 32-bit signed/unsigned integers
  • Float32Array: 32-bit IEEE floating point
  • Float64Array: 64-bit IEEE floating point
  • BigInt64Array / BigUint64Array: 64-bit signed/unsigned integers

Here’s where it gets interesting—multiple views can reference the same buffer:

const buffer = new ArrayBuffer(8);

// View as four 16-bit unsigned integers
const uint16View = new Uint16Array(buffer);
uint16View[0] = 65535; // 0xFFFF
uint16View[1] = 32768; // 0x8000

// View the same buffer as eight 8-bit unsigned integers
const uint8View = new Uint8Array(buffer);
console.log(uint8View[0]); // 255 (0xFF)
console.log(uint8View[1]); // 255 (0xFF)
console.log(uint8View[2]); // 0
console.log(uint8View[3]); // 128 (0x80)

// View as two 32-bit floats
const float32View = new Float32Array(buffer);
console.log(float32View[0]); // Interprets those bytes as a float

You can also create TypedArrays that view only a portion of a buffer:

const buffer = new ArrayBuffer(16);

// View bytes 0-7 as Uint8Array
const firstHalf = new Uint8Array(buffer, 0, 8);

// View bytes 8-15 as Int16Array
const secondHalf = new Int16Array(buffer, 8, 4);

firstHalf[0] = 100;
secondHalf[0] = -500;

TypedArrays behave like regular arrays in many ways—they have length, map, filter, reduce, and other familiar methods. But they enforce type constraints:

const uint8 = new Uint8Array(4);
uint8[0] = 300; // Wraps to 44 (300 % 256)
uint8[1] = -1;  // Wraps to 255

const int8 = new Int8Array(4);
int8[0] = 200;  // Wraps to -56
int8[1] = -200; // Wraps to 56

DataView for Flexible Access

While TypedArrays excel at homogeneous data, DataView provides fine-grained control for heterogeneous binary structures. DataView lets you read and write different types at arbitrary byte offsets, and crucially, control endianness.

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

// Write different types at specific offsets
view.setUint8(0, 255);           // 1 byte at offset 0
view.setInt16(1, -32768, true);  // 2 bytes at offset 1, little-endian
view.setFloat32(3, 3.14159, false); // 4 bytes at offset 3, big-endian
view.setUint32(7, 0xDEADBEEF, true); // 4 bytes at offset 7

// Read them back
console.log(view.getUint8(0));          // 255
console.log(view.getInt16(1, true));    // -32768
console.log(view.getFloat32(3, false)); // 3.14159...
console.log(view.getUint32(7, true).toString(16)); // deadbeef

The boolean parameter controls endianness: true for little-endian, false for big-endian. This matters when parsing binary file formats or network protocols:

// Parse a simple binary file header
function parseHeader(buffer) {
  const view = new DataView(buffer);
  
  return {
    magic: view.getUint32(0, false),      // Big-endian magic number
    version: view.getUint16(4, true),     // Little-endian version
    flags: view.getUint16(6, true),
    dataSize: view.getUint32(8, true),
    checksum: view.getUint32(12, false)
  };
}

Practical Applications

Let’s examine real-world scenarios where ArrayBuffer and TypedArrays shine.

Reading and Processing Binary Files:

async function processBinaryFile(file) {
  const buffer = await file.arrayBuffer();
  const uint8View = new Uint8Array(buffer);
  
  // Calculate checksum
  let checksum = 0;
  for (let i = 0; i < uint8View.length; i++) {
    checksum = (checksum + uint8View[i]) & 0xFF;
  }
  
  return checksum;
}

// Usage with File API
fileInput.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const checksum = await processBinaryFile(file);
  console.log('Checksum:', checksum);
});

Canvas Image Manipulation:

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

// imageData.data is a Uint8ClampedArray (RGBA values 0-255)
const pixels = imageData.data;

// Invert colors
for (let i = 0; i < pixels.length; i += 4) {
  pixels[i] = 255 - pixels[i];       // Red
  pixels[i + 1] = 255 - pixels[i + 1]; // Green
  pixels[i + 2] = 255 - pixels[i + 2]; // Blue
  // pixels[i + 3] is alpha - leave unchanged
}

ctx.putImageData(imageData, 0, 0);

Binary WebSocket Protocol:

// Encode a message: [type:1byte][id:4bytes][payload:variable]
function encodeMessage(type, id, payload) {
  const buffer = new ArrayBuffer(5 + payload.length);
  const view = new DataView(buffer);
  
  view.setUint8(0, type);
  view.setUint32(1, id, true);
  
  const payloadView = new Uint8Array(buffer, 5);
  payloadView.set(new TextEncoder().encode(payload));
  
  return buffer;
}

// Decode incoming message
function decodeMessage(buffer) {
  const view = new DataView(buffer);
  const type = view.getUint8(0);
  const id = view.getUint32(1, true);
  const payload = new TextDecoder().decode(new Uint8Array(buffer, 5));
  
  return { type, id, payload };
}

// WebSocket usage
ws.binaryType = 'arraybuffer';
ws.send(encodeMessage(1, 42, 'Hello'));
ws.onmessage = (event) => {
  const msg = decodeMessage(event.data);
  console.log(msg);
};

Performance Considerations and Best Practices

TypedArrays offer significant performance advantages for numeric operations:

// Benchmark: sum 1 million numbers
function benchmarkSum() {
  const size = 1000000;
  
  // Regular array
  const regularArray = Array.from({ length: size }, (_, i) => i);
  console.time('Regular Array');
  let sum1 = regularArray.reduce((a, b) => a + b, 0);
  console.timeEnd('Regular Array');
  
  // TypedArray
  const typedArray = new Float64Array(size);
  for (let i = 0; i < size; i++) typedArray[i] = i;
  console.time('TypedArray');
  let sum2 = 0;
  for (let i = 0; i < size; i++) sum2 += typedArray[i];
  console.timeEnd('TypedArray');
}

Memory Efficiency: Copying ArrayBuffers can be expensive. Use .slice() when you need a copy, or share views when possible:

// Expensive copy
const copy = new Uint8Array(original);

// Cheap view (shares underlying buffer)
const view = new Uint8Array(original.buffer, original.byteOffset, original.length);

Transferable Objects: When passing data to Web Workers, transfer ownership instead of copying:

// Worker creation
const worker = new Worker('worker.js');
const buffer = new ArrayBuffer(1024 * 1024); // 1MB

// Transfer ownership (zero-copy)
worker.postMessage({ buffer }, [buffer]);

// buffer is now unusable in main thread
console.log(buffer.byteLength); // 0

Common Pitfalls:

  1. Forgetting endianness: Always specify endianness with DataView when dealing with multi-byte values
  2. Alignment issues: Some platforms require aligned access; use DataView for unaligned reads
  3. Overflow behavior: TypedArrays wrap on overflow—always validate input ranges
  4. Detached buffers: After transferring to a worker, the original buffer becomes detached

ArrayBuffer and TypedArrays are essential tools for modern JavaScript development. They enable efficient binary data processing, unlock performance optimizations, and provide the foundation for working with files, graphics, audio, and network protocols. Master these APIs, and you’ll handle binary data with the same confidence you have with strings and objects.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.