In this blog post, we examine ArrayBuffer features that were introduced in ECMAScript 2024:
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.Uint8Array
instances, we can only get and set unsigned 8-bit integers.Float32Array
instances, we can only get and set unsigned 32-bit floats.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.
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.
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 Typed Array 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).
These are the changes introduced by the feature:
new ArrayBuffer(byteLength: number, options?: {maxByteLength?: number})
ArrayBuffer.prototype.resize(newByteLength: number)
get ArrayBuffer.prototype.resizable()
get ArrayBuffer.prototype.maxByteLength()
.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
);
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.undefined
.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$/
);
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 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
ReadableStream
TransformStream
WritableStream
ImageBitmap
OffscreenCanvas
MessagePort
RTCDataChannel
The feature “ArrayBuffer.prototype.transfer
and friends” provides the following new functionality:
ArrayBuffer.prototype.transfer(newLength?: number)
ArrayBuffer.prototype.transferToFixedLength(newLength?: number)
get ArrayBuffer.prototype.detached
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 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);
}
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
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
> 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 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: