ECMAScript 2024 features: resizing and transferring ArrayBuffers

[2024-06-01] dev, javascript, es2024
(Ad, please don’t block)

In this blog post, we examine ArrayBuffer features that were introduced in ECMAScript 2024:

What are ArrayBuffers?  

The following classes provide an API for handling binary data (as in not text) in JavaScript:

  • ArrayBuffer provides storage for binary data and is mostly a black box.
  • There are two wrappers around ArrayBuffers that let us access their data by getting and setting numbers:
    • TypedArrays are used much like normal Arrays but we can only get and set numbers, whose bit-length is fixed per TypedArray class – e.g.:
      • With Uint8Array instances, we can only get and set unsigned 8-bit integers.
      • With Float32Array instances, we can only get and set unsigned 32-bit floats.
    • DataViews let us get and set numbers at arbitrary byte offsets and with all number formats that the API supports. These are some of its methods:
      • DataView.prototype.getUint8()
      • DataView.prototype.setUint8()
      • DataView.prototype.getFloat32()
      • DataView.prototype.setFloat32()

Using these classes looks as follows:

const buf = new ArrayBuffer(16); // 16 bytes of storage
const typedArray = new Uint8Array(buf);
const dataView = new DataView(buf);

typedArray[0] = 127; // write to `buf`
assert.equal(
  dataView.getUint8(0), 127 // read from `buf`
);

A more recent addition to this API is SharedArrayBuffer – an ArrayBuffer whose storage can be shared between any set of agents (where an agent is either the main thread or a web worker).

We’ll mostly ignore SharedArrayBuffers in this blog post and get back to them at the end.

In-place resizable ArrayBuffers  

Before (Shared)ArrayBuffers became resizable, they had fixed sizes. If we wanted one to grow or shrink, we had to allocate a new one and copy the old one over. That costs time and can fragment the address space on 32-bit systems.

Why would we want to resize ArrayBuffers?  

  • In WebAssembly, memory is held by an (Shared)ArrayBuffer (more information). Such memory can grow. Each time it does, a new ArrayBuffer is created and the old one detached. If JavaScript code wraps a TypedArray or a DataView around it, then it can’t use the wrapper without first checking if its ArrayBuffer is still attached. Resizable ArrayBuffers and auto-tracking wrappers would make such code more elegant and more efficient.

  • WebGPU uses ArrayBuffers as wrappers for backing stores. These backing stores change often. That leads to new ArrayBuffers being created and increased garbage collection – which can degrade performance, e.g. during animations. The solution is to “re-point” the same ArrayBuffer to different backing stores. To JavaScript code, this looks like the ArrayBuffer being resized and overwritten. No additional mechanism for re-pointing needs to be introduced (source).

New features for ArrayBuffers  

These are the changes introduced by the feature:

  • The existing constructor gets one more parameter:
    new ArrayBuffer(byteLength: number, options?: {maxByteLength?: number})
    
  • There is one new method and two new getters:
    • ArrayBuffer.prototype.resize(newByteLength: number)
    • get ArrayBuffer.prototype.resizable()
    • get ArrayBuffer.prototype.maxByteLength()
  • The existing method .slice() always returns non-resizable ArrayBuffers.

The options object of the constructor determines whether or not an ArrayBuffer is resizable:

const resizableArrayBuffer = new ArrayBuffer(16, {maxByteLength: 32});
assert.equal(
  resizableArrayBuffer.resizable, true
);

const fixedArrayBuffer = new ArrayBuffer(16);
assert.equal(
  fixedArrayBuffer.resizable, false
);

How Typed Arrays react to changing ArrayBuffer sizes  

This is what constructors of Typed Arrays look like:

new «TypedArray»(
  buffer: ArrayBuffer | SharedArrayBuffer,
  byteOffset?: number,
  length?: number
)

If length is undefined then the .length and .byteLength of the Typed Array instance automatically tracks the length of a resizable buffer:

const buf = new ArrayBuffer(2, {maxByteLength: 4});
// `tarr1` starts at offset 0 (`length` is undefined)
const tarr1 = new Uint8Array(buf);
// `tarr2` starts at offset 2 (`length` is undefined)
const tarr2 = new Uint8Array(buf, 2);

assert.equal(
  tarr1.length, 2
);
assert.equal(
  tarr2.length, 0
);

buf.resize(4);

assert.equal(
  tarr1.length, 4
);
assert.equal(
  tarr2.length, 2
);

If an ArrayBuffer is resized then a wrapper with a fixed length can go out of bounds: The wrapper’s range isn’t covered by the ArrayBuffer anymore. That is treated by JavaScript as if the ArrayBuffer were detached (more on detaching later in this blog post):

  • .length, .byteLength and .byteOffset are zero.
  • Getting elements returns undefined.
  • Setting elements is silently ignored.
  • All element-related methods throw errors.
const buf = new ArrayBuffer(4, {maxByteLength: 4});
const tarr = new Uint8Array(buf, 2, 2);
assert.equal(
  tarr.length, 2
);
buf.resize(3);
// `tarr` is now partially out of bounds
assert.equal(
  tarr.length, 0
);
assert.equal(
  tarr.byteLength, 0
);
assert.equal(
  tarr.byteOffset, 0
);
assert.equal(
  tarr[0], undefined
);
assert.throws(
  () => tarr.at(0),
  /^TypeError: Cannot perform %TypedArray%.prototype.at on a detached ArrayBuffer$/
);

Guidelines given by the ECMAScript specification  

The ECMAScript specification gives the following guidelines for working with resizable ArrayBuffers:

  • We recommend that programs be tested in their deployment environments where possible. The amount of available physical memory differs greatly between hardware devices. Similarly, virtual memory subsystems also differ greatly between hardware devices as well as operating systems. An application that runs without out-of-memory errors on a 64-bit desktop web browser could run out of memory on a 32-bit mobile web browser.

  • When choosing a value for the maxByteLength option for resizable ArrayBuffer, we recommend that the smallest possible size for the application be chosen. We recommend that maxByteLength does not exceed 1,073,741,824 (2^30^ bytes or 1 GiB).

  • Please note that successfully constructing a resizable ArrayBuffer for a particular maximum size does not guarantee that future resizes will succeed.

ArrayBuffer.prototype.transfer and friends  

Preparation: transferring data and detaching  

The web API (not the ECMAScript standard) has long supported structured cloning for safely moving values across realms (globalThis, iframes, web workers, etc.). Some objects can also be transferred: After cloning, the original becomes detached (inaccessible) and ownership switches from the original to the clone. Transfering is usually faster than copying, especially if large amounts of memory are involved. These are the most common classes of transferable objects:

  • ArrayBuffer
  • Streams:
    • ReadableStream
    • TransformStream
    • WritableStream
  • DOM-related data:
    • ImageBitmap
    • OffscreenCanvas
  • Miscellaneous communication:
    • MessagePort
    • RTCDataChannel

New functionality  

The feature “ArrayBuffer.prototype.transfer and friends” provides the following new functionality:

  • Two methods let us explicitly transfer an ArrayBuffer to a new object (we’ll see soon why that is useful):
    • ArrayBuffer.prototype.transfer(newLength?: number)
    • ArrayBuffer.prototype.transferToFixedLength(newLength?: number)
  • One getter tells us if an ArrayBuffer is detached:
    • get ArrayBuffer.prototype.detached

Transferring ArrayBuffers via structuredClone()  

Interestingly, the broadly supported structuredClone() already lets us transfer (and therefore detach) ArrayBuffers:

const original = new ArrayBuffer(16);
const clone = structuredClone(original, {transfer: [original]});

assert.equal(
  original.byteLength, 0
);

assert.equal(
  clone.byteLength, 16
);
assert.equal(
  original.detached, true
);
assert.equal(
  clone.detached, false
);

The ArrayBuffer method .transfer() simply gives us a more concise way to detach an ArrayBuffer:

const original = new ArrayBuffer(16);
const transferred = original.transfer();

assert.equal(
  original.detached, true
);
assert.equal(
  transferred.detached, false
);

Transferring an ArrayBuffer within the same agent  

Transferring is most often used between two agents (main thread or web worker). However, transferring within the same agent can make sense too: If a function gets a (potentially shared) ArrayBuffer as a parameter, it can transfer it so that no external code can interfere with what it does. Example (taken from the ECMAScript proposal and slightly edited):

async function validateAndWriteSafeAndFast(arrayBuffer) {
  const owned = arrayBuffer.transfer();

  // We have `owned` and no one can access its data via
  // `arrayBuffer` now because the latter is detached:
  assert.equal(
    arrayBuffer.detached, true
  );

  // `await` pauses this function – which gives external
  // code the opportunity to access `arrayBuffer`.
  await validate(owned);
  await fs.writeFile("data.bin", owned);
}

How does detaching an ArrayBuffer affect its wrappers?  

Typed Arrays with detached ArrayBuffers  

Preparation:

> const arrayBuffer = new ArrayBuffer(16);
> const typedArray = new Uint8Array(arrayBuffer);
> arrayBuffer.transfer();

Lengths and offsets are all zero:

> typedArray.length
0
> typedArray.byteLength
0
> typedArray.byteOffset
0

Getting elements returns undefined; setting elements fails silently:

> typedArray[0]
undefined
> typedArray[0] = 128
128

All element-related methods throw exceptions:

> typedArray.at(0)
TypeError: Cannot perform %TypedArray%.prototype.at on a detached ArrayBuffer

DataViews with detached ArrayBuffers  

All data-related methods of DataViews throw:

> const arrayBuffer = new ArrayBuffer(16);
> const dataView = new DataView(arrayBuffer);
> arrayBuffer.transfer();
> dataView.byteLength
TypeError: Cannot perform get DataView.prototype.byteLength on a detached ArrayBuffer
> dataView.getUint8(0)
TypeError: Cannot perform DataView.prototype.getUint8 on a detached ArrayBuffer

We can’t create new wrappers with detached ArrayBuffers  

> const arrayBuffer = new ArrayBuffer(16);
> arrayBuffer.transfer();
> new Uint8Array(arrayBuffer)
TypeError: Cannot perform Construct on a detached ArrayBuffer
> new DataView(arrayBuffer)
TypeError: Cannot perform DataView constructor on a detached ArrayBuffer

ArrayBuffer.prototype.transferToFixedLength()  

This method rounds out the API: It transfers and converts a resizable ArrayBuffer to one with a fixed length. That may free up memory that was held in preparation for growth.

Resizing and transferring SharedArrayBuffers  

  • Resizable SharedArrayBuffers can only grow – given that shrinking shared memory is tricky.
  • SharedArrayBuffers can’t be transferred.

Conclusion and further reading  

Resizing and and transferring ArrayBuffers rounds out the Typed Array/DataView/ArrayBuffer API and helps with WebAssembly and other code that uses that API.

If you want to read more about Typed Arrays, DataViews and ArrayBuffers: