bastianplsfix

Buffers and views

An ArrayBuffer is a fixed block of bytes with no interpretation at all. You cannot read it directly, every byte starts at zero, and it stays meaningless until something says what it means. A view says what it means, and there are two kinds: a typed array, where every element has one type and you index it, with a page of its own, typed arrays; and a DataView, where you read any type at any byte offset and choose the byte order per call.

The division is about who defined the layout. A typed array is for data that is yours, a sequence of bytes or floats or 32-bit integers, all the same. A DataView is for a format somebody else specified, where byte 0 is a magic number, byte 2 is a version, and byte 4 is a length.

Create programs/buffers-and-views.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

a buffer is bytes with no meaning

Deno.test("a buffer is bytes with no meaning", () => {
const buffer = new ArrayBuffer(4);

assertStrictEquals(buffer.byteLength, 4);
assertEquals([...new Uint8Array(buffer)], [0, 0, 0, 0]);

const view = new DataView(buffer);
assertStrictEquals(view.getUint8(0), 0);
view.setUint8(0, 5);
assertStrictEquals(view.getUint8(0), 5);

assert(ArrayBuffer.isView(view));
assertFalse(ArrayBuffer.isView(buffer));
});
Check programs/buffers-and-views.test.ts
running 1 test from ./programs/buffers-and-views.test.ts
a buffer is bytes with no meaning ... ok (317µs)

ok | 1 passed | 0 failed (1ms)

new ArrayBuffer(n) allocates n zero bytes, and byteLength is the only thing it tells you about itself: there is no length, because there are no elements yet. A DataView has a get and a set method for every element type, getUint8, getInt16, getFloat32, getBigInt64, and so on down the twelve types, including getFloat16 since ES2025. And ArrayBuffer.isView answers for views only, because the buffer is not a view of itself.

a view can cover part of a buffer

Deno.test("a view can cover part of a buffer", () => {
const buffer = new ArrayBuffer(8);
const window = new DataView(buffer, 2, 4);

assertStrictEquals(window.byteOffset, 2);
assertStrictEquals(window.byteLength, 4);
assertStrictEquals(window.buffer, buffer);
});
a view can cover part of a buffer ... ok (23µs)

Both kinds of view take an optional offset and length, both in bytes for a DataView. byteOffset is where the view starts, and buffer reaches the whole block, which matters more than it looks and comes back near the end of this page.

two views over one buffer are one piece of memory

Deno.test("two views over one buffer are one piece of memory", () => {
const buffer = new ArrayBuffer(4);
const asWords = new Uint16Array(buffer);
const asBytes = new Uint8Array(buffer);

asWords[0] = 0x0102;

assertStrictEquals(asBytes.length, 4);
assertStrictEquals(asWords.length, 2);
assertEquals(
[...asBytes].filter((byte) => byte !== 0).sort(),
[1, 2],
);
});
two views over one buffer are one piece of memory ... ok (42µs)

This is the model in one example: four bytes of memory, seen as two 16-bit words and as four bytes at the same time, and a write through one view is immediately visible through the other. That is the point of separating buffers from views, and also the hazard, because two names that look like independent collections are one piece of memory. Note what the assertion carefully does not claim: the two bytes are 1 and 2, and which comes first depends on the machine, which the filter-and-sort sidesteps and the next step confronts.

a typed array follows the platform, a DataView does not

Every mainstream CPU today is little-endian, so writing 0x4321 through a typed array puts 0x21 first in memory. Predict what a DataView's setUint16 does with no flag:

Deno.test("a typed array follows the platform, a DataView does not", () => {
const view = new DataView(new ArrayBuffer(2));
view.setUint16(0, 0x4321);
assertEquals([...new Uint8Array(view.buffer)], [0x21, 0x43]);
});
Check programs/buffers-and-views.test.ts
running 4 tests from ./programs/buffers-and-views.test.ts
...
a typed array follows the platform, a DataView does not ... FAILED (8ms)

ERRORS

a typed array follows the platform, a DataView does not => ./programs/buffers-and-views.test.ts:51:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
- 67,
33,
+ 67,
]

FAILURES

a typed array follows the platform, a DataView does not => ./programs/buffers-and-views.test.ts:51:6

FAILED | 3 passed | 1 failed (11ms)

error: Test failed

Big end first: 67 is 0x43, and it came out ahead of 33, which is 0x21. Endianness is the order in which a multi-byte value's bytes are stored, with big-endian putting the most significant byte first, so 0x4321 is 0x43 then 0x21, and little-endian putting it last. A typed array uses the platform's order and gives you no say, because typed arrays exist to hand memory to native code, which follows the CPU. A DataView lets you choose per call, because it exists to read formats, which are fixed across platforms. And here is the trap: DataView defaults to big-endian, which is not the order any machine you own uses. That is deliberate, since big-endian is network byte order and what most wire formats specify, but it means the shortest call is rarely the one matching your own memory. Correct the prediction, and derive the platform's order rather than asserting it:

Deno.test("a typed array follows the platform, a DataView does not", () => {
const platformIsLittleEndian =
new Uint8Array(new Uint16Array([0x4321]).buffer)[0] === 0x21;
const fromTypedArray = new Uint8Array(new Uint16Array([0x4321]).buffer);

const view = new DataView(new ArrayBuffer(2));
view.setUint16(0, 0x4321);
assertEquals([...new Uint8Array(view.buffer)], [0x43, 0x21]);

view.setUint16(0, 0x4321, true);
assertEquals([...new Uint8Array(view.buffer)], [0x21, 0x43]);

view.setUint16(0, 0x4321, platformIsLittleEndian);
assertEquals([...new Uint8Array(view.buffer)], [...fromTypedArray]);
assertStrictEquals(view.getUint16(0, platformIsLittleEndian), 0x4321);
});
a typed array follows the platform, a DataView does not ... ok (128µs)

The platformIsLittleEndian probe is how you would check at run time if you ever needed to, and a reasonable habit for a test that must pass anywhere.

an offset is not an index

Deno.test("an offset is not an index", () => {
const view = new DataView(new ArrayBuffer(4));

assertThrows(
() => view.getUint8(-1),
RangeError,
"Offset is outside the bounds of the DataView",
);

assertEquals([...Uint8Array.of(1, 2, 3).slice(-1)], [3]);
});
an offset is not an index ... ok (298µs)

Two kinds of number appear in this API and they behave differently. An index passed to a method may be negative and counts from the end, as everywhere else. An offset passed to a DataView must be non-negative, and a negative one throws. There is no rule for telling them apart from the shape of a call, which is the honest answer: read the documentation for the method you are using. The useful heuristic is that anything measured in bytes is an offset.

a resizable buffer, and the views that track it

Deno.test("a resizable buffer, and the views that track it", () => {
const resizable = new ArrayBuffer(2, { maxByteLength: 4 });
assert(resizable.resizable);
assertStrictEquals(resizable.maxByteLength, 4);

const fixed = new ArrayBuffer(2);
assertFalse(fixed.resizable);
assertStrictEquals(fixed.maxByteLength, 2);

assertFalse(resizable.slice(0).resizable);

const fromStart = new Uint8Array(resizable);
const fromTwo = new Uint8Array(resizable, 2);

assertStrictEquals(fromStart.length, 2);
assertStrictEquals(fromTwo.length, 0);

resizable.resize(4);

assertStrictEquals(fromStart.length, 4);
assertStrictEquals(fromTwo.length, 2);
});
a resizable buffer, and the views that track it ... ok (68µs)

Passing maxByteLength makes a buffer resizable, and without it a buffer is fixed forever. Note that maxByteLength answers on a fixed buffer too, reporting its own length, so the property to ask about capability is resizable, and a slice is never resizable. Then the second half: a view created without an explicit length tracks the buffer, so after the resize both views grew, and a view starting past the current end is legal and simply empty until the buffer reaches it. That is a genuinely useful arrangement, because a view onto a growing buffer stays correct without being rebuilt.

a fixed-length view can fall out of bounds, and then it goes quiet

Give a view an explicit length and it no longer tracks, so shrinking the buffer can leave it pointing at bytes that are gone. Shrink by one byte, write through the window, and predict the read-back:

Deno.test("a fixed-length view can fall out of bounds, and then it goes quiet", () => {
const buffer = new ArrayBuffer(4, { maxByteLength: 4 });
const window = new Uint8Array(buffer, 2, 2);
assertStrictEquals(window.length, 2);

buffer.resize(3);

window[0] = 9;
assertStrictEquals(window[0], 9);
});
Check programs/buffers-and-views.test.ts
running 7 tests from ./programs/buffers-and-views.test.ts
...
a fixed-length view can fall out of bounds, and then it goes quiet ... FAILED (8ms)

ERRORS

a fixed-length view can fall out of bounds, and then it goes quiet => ./programs/buffers-and-views.test.ts:106:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- undefined
+ 9

FAILURES

a fixed-length view can fall out of bounds, and then it goes quiet => ./programs/buffers-and-views.test.ts:106:6

FAILED | 6 passed | 1 failed (11ms)

error: Test failed

The write went nowhere. This is the worst failure mode in the entry, and the corrected test is worth reading slowly: length, byteLength, and byteOffset all become zero, including the offset you supplied, reading an element gives undefined, writing one is silently ignored, and only a method call throws, by which point the write that mattered has already been discarded. Three of the four symptoms are silence, so a program that shrinks a buffer while holding fixed-length views does not crash; it stops recording data. Prefer tracking views, and treat an explicit length as a claim you are responsible for. Correct the prediction:

Deno.test("a fixed-length view can fall out of bounds, and then it goes quiet", () => {
const buffer = new ArrayBuffer(4, { maxByteLength: 4 });
const window = new Uint8Array(buffer, 2, 2);
assertStrictEquals(window.length, 2);

buffer.resize(3);

assertStrictEquals(window.length, 0);
assertStrictEquals(window.byteLength, 0);
assertStrictEquals(window.byteOffset, 0);
assertStrictEquals(window[0], undefined);

window[0] = 9;
assertStrictEquals(window[0], undefined);

assertThrows(
() => window.at(0),
TypeError,
"Cannot perform %TypedArray%.prototype.at on a detached or out-of-bounds ArrayBuffer",
);
});
a fixed-length view can fall out of bounds, and then it goes quiet ... ok (63µs)

transferring moves the bytes and detaches the original

Deno.test("transferring moves the bytes and detaches the original", () => {
const original = new ArrayBuffer(16);
const clone = structuredClone(original, { transfer: [original] });

assertStrictEquals(original.byteLength, 0);
assert(original.detached);
assertStrictEquals(clone.byteLength, 16);
assertFalse(clone.detached);

const second = new ArrayBuffer(16);
const moved = second.transfer();
assert(second.detached);
assertStrictEquals(moved.byteLength, 16);

const growable = new ArrayBuffer(8, { maxByteLength: 16 });
const settled = growable.transferToFixedLength();
assertFalse(settled.resizable);
assert(growable.detached);
});
transferring moves the bytes and detaches the original ... ok (1ms)

Transferring hands the bytes to a new buffer and leaves the old one detached: length zero, detached true, and unusable. Nothing is copied, which is why this is worth doing for large buffers. Three ways in. structuredClone with a transfer list is the general web mechanism, and the one used to move a buffer to a worker. transfer() is the concise local form of the same thing. transferToFixedLength() transfers and drops resizability, which can release memory a growable buffer was holding in reserve. detached is readable, so you can ask, and you will rarely want to, because a detached buffer usually means a bug rather than a state to branch on.

what detaching does to views that already exist

Deno.test("what detaching does to views that already exist", () => {
const buffer = new ArrayBuffer(4);
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
buffer.transfer();

assertStrictEquals(bytes.length, 0);
assertStrictEquals(bytes[0], undefined);
bytes[0] = 9;
assertStrictEquals(bytes[0], undefined);

assertThrows(
() => view.byteLength,
TypeError,
"Cannot perform get DataView.prototype.byteLength on a detached or out-of-bounds ArrayBuffer",
);
assertThrows(
() => view.getUint8(0),
TypeError,
"Cannot perform DataView.prototype.getUint8 on a detached or out-of-bounds ArrayBuffer",
);

assertThrows(
() => new Uint8Array(buffer),
TypeError,
"Cannot perform Construct on a detached ArrayBuffer",
);
assertThrows(
() => new DataView(buffer),
TypeError,
"Cannot perform DataView constructor on a detached ArrayBuffer",
);
});
what detaching does to views that already exist ... ok (82µs)

Three different behaviours for the same underlying condition, worth knowing because you will diagnose it from whichever one you happen to hit. A typed array goes quiet, exactly as in the out-of-bounds case. A DataView throws from every member, including get byteLength, so even asking how big it is fails. And constructing a new view over a detached buffer throws immediately, with a different message for each kind. The messages say detached or out-of-bounds, which is the runtime telling you these two situations are one situation internally, and worth quoting because the same wording appeared for the resize case above, where it can look like the wrong error.

why you would transfer inside one program

Transferring is usually described as a way to move data between a worker and the main thread, and it is. It has a second use that is about ownership rather than speed. A function given a buffer cannot know who else holds a view onto it, and calling transfer() on the way in gives the function bytes nothing else can reach, because every existing view is now looking at a detached buffer. That matters most around an await, where other code gets to run in the middle of your function, the habit the event loop page builds, and could otherwise change the bytes you are halfway through validating. Stated as a rule: transfer when you need the bytes to stop changing, not when you want them to arrive faster. No test on this step, because the guarantee is the absence of every other reader, and the detaching steps above already measured the readers one at a time.

SharedArrayBuffer, and the type it explains

Deno.test("SharedArrayBuffer, and the type it explains", () => {
const shared = new SharedArrayBuffer(4, { maxByteLength: 8 });

assert(shared.growable);
assertStrictEquals(shared.maxByteLength, 8);
shared.grow(8);
assertStrictEquals(shared.byteLength, 8);

assertFalse("resize" in shared);
assertFalse("resizable" in shared);
assertFalse("transfer" in shared);
assertFalse("detached" in shared);
});
SharedArrayBuffer, and the type it explains ... ok (33µs)

A SharedArrayBuffer is memory that two agents, the main thread and a worker, can read at the same time, the parallelism the event loop page set aside. It differs from an ArrayBuffer in three ways: it is cloned rather than transferred, since the point is that both sides keep access; it can grow but never shrink, because shrinking memory another thread is reading is not something anyone wants to specify; and concurrent access to it is made well-defined by Atomics, a namespace of operations that complete indivisibly. That is as far as this entry goes, because using it properly needs concurrency rather than bytes. The four in checks are the reason TypeScript distinguishes Uint8Array<ArrayBuffer> from Uint8Array<ArrayBufferLike>: the wider type admits this class, which really does lack resize, resizable, transfer, and detached, a distinction the typed arrays page returns to.

build a DataView from the view you were given

A Uint8Array you receive may be a window onto a larger buffer. tail starts at offset 2, where the interesting bytes are, so predict what a DataView built from tail.buffer reads:

Deno.test("build a DataView from the view you were given", () => {
const whole = Uint8Array.of(0, 0, 0xca, 0xfe, 0, 0, 0, 0);
const tail = whole.subarray(2);

const view = new DataView(tail.buffer);
assertStrictEquals(view.getUint16(0, false), 0xcafe);
});
Check programs/buffers-and-views.test.ts
running 11 tests from ./programs/buffers-and-views.test.ts
...
build a DataView from the view you were given ... FAILED (8ms)

ERRORS

build a DataView from the view you were given => ./programs/buffers-and-views.test.ts:200:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- 0
+ 51966

FAILURES

build a DataView from the view you were given => ./programs/buffers-and-views.test.ts:200:6

FAILED | 10 passed | 1 failed (14ms)

error: Test failed

Zero, read from the wrong place, and read successfully. tail.buffer is the whole block rather than your part of it, so passing it straight to new DataView starts you at byte zero of somebody else's data, and reads succeed with wrong values, which is the worst kind of wrong. Always pass byteOffset and byteLength along with it. Correct the prediction and keep both views for the contrast:

Deno.test("build a DataView from the view you were given", () => {
const whole = Uint8Array.of(0, 0, 0xca, 0xfe, 0, 0, 0, 0);
const tail = whole.subarray(2);

const wrong = new DataView(tail.buffer);
const right = new DataView(tail.buffer, tail.byteOffset, tail.byteLength);

assertStrictEquals(wrong.getUint16(0, false), 0);
assertStrictEquals(right.getUint16(0, false), 0xcafe);
});
build a DataView from the view you were given ... ok (21µs)

endianness said out loud

Deno.test("endianness said out loud", () => {
type Header = { magic: number; version: number; length: number };

function writeHeader(header: Header): Uint8Array<ArrayBuffer> {
const bytes = new Uint8Array(8);
const view = new DataView(bytes.buffer);

view.setUint16(0, header.magic, false);
view.setUint16(2, header.version, false);
view.setUint32(4, header.length, false);

return bytes;
}

assertEquals(
[...writeHeader({ magic: 0xcafe, version: 2, length: 7 })],
[0xca, 0xfe, 0, 2, 0, 0, 0, 7],
);
});
endianness said out loud ... ok (34µs)

false means big-endian and is what the default already does, and writing it down turns an invisible assumption into a visible decision, so the next reader does not have to remember which way the default goes. The return type says Uint8Array<ArrayBuffer> because the function built the buffer itself and can promise it is the real, transferable kind.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/buffers-and-views.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/dates-and-times.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/loops.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 12 tests from ./programs/buffers-and-views.test.ts
a buffer is bytes with no meaning ... ok (296µs)
a view can cover part of a buffer ... ok (25µs)
two views over one buffer are one piece of memory ... ok (41µs)
a typed array follows the platform, a DataView does not ... ok (44µs)
an offset is not an index ... ok (274µs)
a resizable buffer, and the views that track it ... ok (74µs)
a fixed-length view can fall out of bounds, and then it goes quiet ... ok (53µs)
transferring moves the bytes and detaches the original ... ok (1ms)
what detaching does to views that already exist ... ok (83µs)
SharedArrayBuffer, and the type it explains ... ok (26µs)
build a DataView from the view you were given ... ok (28µs)
endianness said out loud ... ok (37µs)
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 13 tests from ./programs/dates-and-times.test.ts
...
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
...
running 14 tests from ./programs/loops.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 612 passed | 0 failed (1s)

Twelve tests, and the practice is short. A DataView for a format you did not define, and a typed array for a layout you did, which decides it nearly every time. Be explicit about endianness at every DataView call, even when the default is what you want. Build a DataView from the offset and length of the view you were given. Keep maxByteLength as small as the work allows, which is the specification's own advice, and remember that a successful allocation now does not promise a successful resize later. Prefer tracking views to fixed-length ones over a resizable buffer, since the out-of-bounds failure is mostly silent. And treat a detached buffer as a bug rather than a condition to check for, because the checks are awkward and the situation means somebody transferred what you were using.