TypedArrays and ArrayBuffers: How V8 Maps JavaScript Arrays onto Memory

An ArrayBuffer owns the bytes; a TypedArray is a window onto them. V8 keeps the pointer outside the sandbox, tracks detachment with a validity cell, and can transfer a buffer without copying.

TL;DR: A JSArrayBuffer owns a BackingStore (the raw bytes, allocated outside the V8 heap and registered in the sandbox’s external pointer table); a JSTypedArray is a view - a buffer reference plus a byte offset and a length - that carries no memory of its own. Detaching a buffer swaps its backing store for an empty one, flips a was_detached bit, and poisons the detaching protector so that every optimized function touching a typed array deoptimizes. ArrayBuffer.prototype.transfer can move the bytes without copying them at all.

JavaScript has two kinds of array. A normal Array is a heap object with element storage inside the V8 heap; a TypedArray is a window onto a block of bytes that lives outside it. The distinction matters because the block of bytes might be shared with another thread, might be memory-mapped from a file, or might be a WebAssembly linear memory.

V8’s implementation splits the two responsibilities cleanly. The ArrayBuffer owns the bytes and knows how to give them up. The TypedArray and DataView are pure views: they say “bytes [offset, offset+length) of this buffer, interpreted as Int32”.

The two objects

The object layout is small and tells you most of the story:

torque
// src/objects/js-array-buffer.tq:19
extern class JSArrayBuffer extends JSAPIObjectWithEmbedderSlots {
  views_or_detach_key: Weak<JSArrayBufferView>|Smi|Cell;
  // A BoundedSize if the sandbox is enabled
  raw_byte_length: uintptr;
  // A BoundedSize if the sandbox is enabled
  raw_max_byte_length: uintptr;
  // A SandboxedPtr if the sandbox is enabled
  backing_store: RawPtr;
  extension: ExternalPointer;
  bit_field: JSArrayBufferBits;

and the view:

torque
// src/objects/js-array-buffer.tq:75
extern class JSArrayBufferView extends JSAPIObjectWithEmbedderSlots {
  buffer: JSArrayBuffer;
  bit_field: JSArrayBufferViewBits;  // 32bit
  @if(TAGGED_SIZE_8_BYTES) optional_padding: uint32;
  // A BoundedSize if the sandbox is enabled
  raw_byte_offset: uintptr;
  // A BoundedSize if the sandbox is enabled
  raw_byte_length: uintptr;
}

The typed array adds one more thing:

torque
// src/objects/js-array-buffer.tq:121
extern class JSTypedArray extends JSArrayBufferView {
  // A BoundedSize if the sandbox is enabled
  raw_length: uintptr;
  // A SandboxedPtr if the sandbox is enabled
  external_pointer: RawPtr;
  base_pointer: ByteArray|Smi;

Three things to notice.

backing_store is a SandboxedPtr, not a plain pointer. With the sandbox enabled, V8 stores an offset from the cage base rather than a raw address, so a compromised JIT-generated code cannot fabricate a pointer to arbitrary memory:

cpp
// src/sandbox/sandboxed-pointer-inl.h:18
V8_INLINE Address ReadSandboxedPointerField(Address field_address,
                                            PtrComprCageBase cage_base) {
#ifdef V8_ENABLE_SANDBOX
  SandboxedPointer_t sandboxed_pointer =
      base::ReadUnalignedValue<SandboxedPointer_t>(field_address);

  Address offset = sandboxed_pointer >> kSandboxedPointerShift;
  Address pointer = cage_base.address() + offset;

and writing one asserts that the target is inside the sandbox:

cpp
// src/sandbox/sandboxed-pointer-inl.h:32
V8_INLINE void WriteSandboxedPointerField(Address field_address,
                                          PtrComprCageBase cage_base,
                                          Address pointer) {
#ifdef V8_ENABLE_SANDBOX
  // The pointer must point into the sandbox.
  CHECK(Sandbox::current()->Contains(pointer));

base_pointer is a ByteArray|Smi union. This is the on-heap versus off-heap distinction. If the bytes live inside the V8 heap (which happens for small on-heap typed arrays), base_pointer holds the ByteArray and external_pointer holds the offset into it. If the bytes live outside, base_pointer is Smi::zero() and external_pointer holds the address. The data pointer is then the sum:

cpp
// src/objects/js-array-buffer-inl.h:496
void* JSTypedArray::DataPtr() {
  // Zero-extend Tagged_t to Address according to current compression scheme
  // so that the addition with |external_pointer| (which already contains
  // compensated offset value) will decompress the tagged value.
  // See JSTypedArray::ExternalPointerCompensationForOnHeapArray() for details.
  static_assert(kOffHeapDataPtrEqualsExternalPointer);
  return reinterpret_cast<void*>(external_pointer() +
                                 static_cast<Tagged_t>(base_pointer().ptr()));
}

The trick is that on-heap arrays can be moved by the garbage collector, so their address must not be baked into the object. Keeping the base in a tagged field and the offset in a raw field means the GC only has to fix up the tag.

extension is an ExternalPointer. This is the ArrayBufferExtension object, which owns the BackingStore - the actual std::shared_ptr to the memory - and is tracked by the GC so that the memory can be freed when the buffer dies. In pointer-compressed builds it is registered in the sandbox’s external pointer table, which is what makes it managed:

cpp
// src/objects/js-array-buffer.h:283
class ArrayBufferExtension final
#ifdef V8_COMPRESS_POINTERS
    : public ExternalPointerTable::ManagedResource {
#else
    : public Malloced {
#endif  // V8_COMPRESS_POINTERS

ManagedResource is a base class whose whole purpose is to make the “external object outlives the table entry” relationship explicit:

cpp
// src/sandbox/external-pointer-table.h:391
  // A resource outside of the V8 heap whose lifetime is tied to something
  // inside the V8 heap. This class makes that relationship explicit.
  //
  // Knowing about such objects is important for the sandbox to guarantee
  // memory safety. In particular, it is necessary to prevent issues where the
  // external resource is destroyed before the entry in the
  // ExternalPointerTable (EPT) that references it is freed. In that case, the
  // EPT entry would then contain a dangling pointer which could be abused by
  // an attacker to cause a use-after-free outside of the sandbox.

and when the extension is finally destroyed, it zaps its table entry so that a stale handle can never be dereferenced:

cpp
// src/heap/array-buffer-sweeper.cc:440
void ArrayBufferSweeper::FinalizeAndDelete(ArrayBufferExtension* extension) {
#ifdef V8_COMPRESS_POINTERS
  extension->ZapExternalPointerTableEntry();
#endif  // V8_COMPRESS_POINTERS
  delete extension;
}

Allocating the bytes

The bytes are allocated by the embedder’s v8::ArrayBuffer::Allocator, not by V8’s heap. BackingStore::Allocate is the whole story for a fixed-length, non-resizable buffer:

cpp
// src/objects/backing-store.cc:229
// Allocate a backing store using the array buffer allocator from the embedder.
std::unique_ptr<BackingStore> BackingStore::Allocate(
    Isolate* isolate, size_t byte_length, SharedFlag shared,
    InitializedFlag initialized) {
  void* buffer_start = nullptr;
  auto allocator = isolate->array_buffer_allocator();
  CHECK_NOT_NULL(allocator);
  if (byte_length > allocator->MaxAllocationSize()) return {};

and the default embedder allocator is a plain malloc/calloc:

cpp
// src/api/api.cc:372
class ArrayBufferAllocator : public v8::ArrayBuffer::Allocator {
 public:
  void* Allocate(size_t length) override { return base::Calloc(length, 1); }

  void* AllocateUninitialized(size_t length) override {
    return base::Malloc(length);
  }

  void Free(void* data, size_t) override { base::Free(data); }

Note the two entry points. Allocate zeroes the memory; AllocateUninitialized does not. V8 uses the uninitialised variant only where it can prove it will overwrite every byte before user code sees it - the transfer builtin allocates a fresh buffer and then copies into it, and the ArrayBufferConstructor_DoNotInitialize builtin exists purely for that case:

cpp
// src/builtins/builtins-arraybuffer.cc:187
// This is a helper to construct an ArrayBuffer with uinitialized memory.
// This means the caller must ensure the buffer is totally initialized in
// all cases, or we will expose uinitialized memory to user code.
BUILTIN(ArrayBufferConstructor_DoNotInitialize) {
  HandleScope scope(isolate);

Resizable buffers (new ArrayBuffer(n, {maxByteLength: m})) take a completely different path. They reserve the whole maximum up front and commit only the pages they need:

cpp
// src/objects/backing-store.cc:300
std::unique_ptr<BackingStore> BackingStore::TryAllocateAndPartiallyCommitMemory(
    Isolate* isolate, size_t byte_length, size_t max_byte_length,
    size_t page_size, size_t initial_pages, size_t maximum_pages,
    WasmMemoryFlag wasm_memory, SharedFlag shared,
    HasGuardRegions has_guard_regions) {

The reservation is made with no access at all:

cpp
// src/objects/backing-store.cc:340
  //--------------------------------------------------------------------------
  // Allocate pages (inaccessible by default).
  //--------------------------------------------------------------------------
  void* allocation_base = nullptr;

and then the initial pages are committed read/write. This is why a resizable array buffer that has never been grown still holds the address space for its maximum: growth is a permission change, not an allocation, and so cannot fail with an out-of-memory error at the wrong moment.

Under the sandbox, the page allocator comes from the isolate group rather than the platform, so the bytes are guaranteed to be inside the sandbox:

cpp
// src/objects/backing-store.cc:354
#ifdef V8_ENABLE_SANDBOX
  IsolateGroup* group =
      isolate ? isolate->isolate_group() : IsolateGroup::current();
  DCHECK(group);
  std::shared_ptr<PageAllocator> page_allocator_shared_ptr =
      group->GetBackingStorePageAllocator().lock();
  PageAllocator* page_allocator = page_allocator_shared_ptr.get();
#else
  PageAllocator* page_allocator = GetPlatformPageAllocator();
#endif

and for the plain (non-resizable) path the sandbox check is an assertion after the fact:

cpp
// src/objects/backing-store.cc:258
#ifdef V8_ENABLE_SANDBOX
    // Check to catch use of a non-sandbox-compatible ArrayBufferAllocator.
    CHECK_WITH_MSG(isolate->isolate_group()->sandbox()->Contains(buffer_start),
                   "When the V8 Sandbox is enabled, ArrayBuffer backing stores "

WebAssembly memories use the page allocator too, with guard regions around the memory so that an out-of-bounds access traps rather than corrupting something else. That is a different article, but the point is that the BackingStore abstraction is what lets the same JSArrayBuffer type back all three cases: a malloc’d block, a reserved-and-committed region, and a guarded wasm memory.

Detachment: the bit, the store, and the protector

Detaching a buffer means “the bytes are gone, and any view onto them must now fail”. V8 does this in three moves.

Move one: steal the extension. The extension owns the BackingStore, which owns the std::shared_ptr to the memory. DetachInternal extracts the extension, hands it to the sweeper, and drops the backing store:

cpp
// src/objects/js-array-buffer.cc:221
void JSArrayBuffer::DetachInternal(DirectHandle<JSArrayBuffer> array_buffer,
                                   bool force_for_wasm_memory,
                                   Isolate* isolate) {
  {
    DisallowGarbageCollection disallow_gc;
    ArrayBufferExtension* extension =
        array_buffer->extract_extension(isolate, disallow_gc);
    if (extension) {
      isolate->heap()->DetachArrayBufferExtension(extension);
      // Prevent concurrent detachment vs background copying if an attacker
      // swapped the extension pointer to a shared buffer's extension.
      SBXCHECK(!extension->is_shared());
      std::shared_ptr<BackingStore> backing_store =

Move two: flip the bit and empty the pointer.

cpp
// src/objects/js-array-buffer.cc:239
  array_buffer->set_was_detached(true, kReleaseStore);

  if (Protectors::IsArrayBufferDetachingIntact(isolate) &&
      !TryDetachViews(array_buffer, isolate)) {
    Protectors::InvalidateArrayBufferDetaching(isolate);
  }

  DCHECK(!array_buffer->is_shared());
  array_buffer->set_backing_store(isolate, EmptyBackingStoreBuffer());

The Protectors::InvalidateArrayBufferDetaching is the important bit. Optimized code that loads from a typed array must know that the buffer might have been detached; V8 handles that by installing a dependency on a protector cell - a one-word cell that is invalidated when detachment happens. Code that depends on the protector deoptimizes.

That is why TryDetachViews exists and why it can fail: V8 tries to convert the views into a special “detached” map so that they fail fast, but only if it can track all of them:

cpp
// src/objects/js-array-buffer.cc:197
bool JSArrayBuffer::TryDetachViews(DirectHandle<JSArrayBuffer> array_buffer,
                                   Isolate* isolate) {
  if (!v8_flags.track_array_buffer_views) return false;

  Tagged<MaybeObject> views = array_buffer->views();
  if (views == kNoView) return true;

The views_or_detach_key field is a deliberately overloaded one-word slot that can hold a weak reference to a single view, or the sentinel kManyViews meaning “there are several and I gave up tracking them”. If there is exactly one view, V8 can migrate it to the JSDetachedTypedArray map - a different instance type - so that the type check alone tells you the array is dead:

cpp
// src/objects/js-array-buffer.cc:182
void JSTypedArray::MarkDetached(DirectHandle<JSTypedArray> typed_array,
                                Isolate* isolate) {
  DCHECK(!typed_array->is_on_heap());
  DirectHandle<Map> current_map(typed_array->map(), isolate);
  MapUpdater update(isolate, current_map);
  DirectHandle<Map> new_map = update.ChangeInstanceType(
      MapUpdater::InstanceTypeChange::kTypedArrayDetaching);
  JSObject::MigrateToMap(isolate, typed_array, new_map);

If it cannot track the views, it falls back to the protector, which is coarser but always correct.

Move three: the views notice. Every operation on a view checks the buffer’s detached bit. In Torque that is a macro; in C++ it is IsDetachedOrOutOfBounds:

cpp
// src/objects/js-array-buffer-inl.h:343
bool JSArrayBufferView::IsDetachedOrOutOfBounds() const {
  Tagged<JSArrayBuffer> backing_buffer = buffer();
  if (backing_buffer->was_detached()) {
    return true;
  }
  if (!is_backed_by_rab()) {
    // TypedArrays backed by GSABs or regular AB/SABs are never out of bounds.
    // This shortcut is load-bearing; this enables determining
    // IsDetachedOrOutOfBounds without consulting the BackingStore.
    return false;
  }

The shortcut is load-bearing comment is a good example of V8’s style: the fast path is documented as required for correctness elsewhere, not merely as an optimisation.

And the builtin-level check is a single flag test in generated code:

cpp
// src/builtins/builtins-typed-array-gen.cc:292
  // If the typed array's buffer is detached or immutable, throw
  TNode<JSArrayBuffer> buffer = LoadJSArrayBufferViewBuffer(CAST(obj));
  GotoIf(IsDetachedBuffer(buffer), &fail);
  if (access_mode == TypedArrayAccessMode::kWrite) {
    GotoIf(IsImmutableArrayBuffer(buffer), &fail);

In optimized code the check is a branch on the protector cell instead, and the length load is folded to zero if the buffer was detached:

cpp
// src/compiler/js-call-reducer.cc:8781
  if (!dependencies()->DependOnArrayBufferDetachingProtector()) {
    length =
        a.MachineSelectIf<Number>(a.ArrayBufferViewDetachedBit(typed_array))
            .Then([&]() { return a.NumberConstant(0); })
            .Else([&]() { return length; })
            .ExpectFalse()
            .Value();
  }

Transfer: moving bytes without copying them

ArrayBuffer.prototype.transfer is the operation that motivated a lot of V8’s design. The idea is to hand the same memory to a new buffer and detach the old one - zero copy, and the old buffer becomes unusable so there is no aliasing.

The implementation is a three-way case split, and the comment in the middle case explains the optimisation:

cpp
// src/builtins/builtins-arraybuffer.cc:748
    // Case 2: We can reuse the same BackingStore.
    auto from_backing_store = array_buffer->GetBackingStore();
    if (from_backing_store && !from_backing_store->is_resizable_by_js() &&
        !resizable && new_byte_length == array_buffer->GetByteLength()) {
      // TODO(syg): Consider realloc when the default ArrayBuffer allocator's
      // Reallocate does better than copy.
      //
      // See https://crbug.com/330575496#comment27

      // 15. Perform ! DetachArrayBuffer(arrayBuffer).
      JSArrayBuffer::Detach(array_buffer).Check();

      // 9. Let newBuffer be ? AllocateArrayBuffer(%ArrayBuffer%, newByteLength,
      //    newMaxByteLength).

So: same length and not resizable means the backing store is moved, not copied. Different length means a real copy, with the tail zero-filled:

cpp
// src/builtins/builtins-arraybuffer.cc:803
      size_t from_byte_length = array_buffer->GetByteLength();
      if (new_byte_length <= from_byte_length) {
        CopyBytes(to_data, from_data, new_byte_length);
      } else {
        CopyBytes(to_data, from_data, from_byte_length);
        memset(to_data + from_byte_length, 0,
               new_byte_length - from_byte_length);
      }

      // 15. Perform ! DetachArrayBuffer(arrayBuffer).

Note the ordering: the copy happens before the detach, because the source bytes have to still be readable.

And there is a third path for the empty-buffer case, which does not need a backing store at all.

Transfer is also the mechanism behind “passing a buffer to a worker”. The embedder’s structured-clone implementation calls ValueSerializer::TransferArrayBuffer, which records the buffer in a transfer map:

cpp
// src/objects/value-serializer.cc:483
void ValueSerializer::TransferArrayBuffer(
    uint32_t transfer_id, DirectHandle<JSArrayBuffer> array_buffer) {
  DCHECK(!array_buffer_transfer_map_.Find(array_buffer));
  DCHECK(!array_buffer->is_shared());
  array_buffer_transfer_map_.Insert(array_buffer, transfer_id);
}

and the serializer then writes a transfer tag instead of the bytes:

cpp
// src/objects/value-serializer.cc:1065
  uint32_t* transfer_entry = array_buffer_transfer_map_.Find(array_buffer);
  if (transfer_entry) {
    WriteTag(SerializationTag::kArrayBufferTransfer);
    WriteVarint(*transfer_entry);

The bytes never go through the serializer at all. The receiving side is handed the buffer out of band, and the embedder is responsible for detaching the source. V8 provides the pieces - v8::ArrayBuffer::Detach for the embedder, and ArrayBuffer.prototype.transfer for JS - but the actual “detach when I post it to a worker” policy lives above V8, in the embedder’s structured clone.

Resizing: length tracking versus RAB versus GSAB

V8 supports four flavours of view, and the comment in the Torque file spells them out:

torque
// src/objects/js-array-buffer.tq:61
// We have 4 different DataViews & TypedArrays:
// 1) Normal (backed by AB / SAB) or non-length tracking backed by GSAB (can't
// go oob once constructed)
// 2) Non-length tracking backed by RAB (can go oob once constructed)
// 3) Length-tracking backed by RAB (JSArrayBuffer stores the length)
// 4) Length-tracking backed by GSAB (BackingStore stores the length)

The distinction between 2 and 3 is a security distinction. A length- tracking view over a resizable buffer has byte_length == 0 in the view itself and reads the real length from the buffer, so it can never be stale. A non-length-tracking view keeps its own length, and if the buffer shrinks underneath it, the view is now out of bounds - and V8 checks that on every access:

cpp
// src/objects/js-array-buffer-inl.h:354
  size_t buffer_byte_length =
      backing_buffer->GetBackingStore()->byte_length();
  // Length-tracking ArrayBufferViews have byte_length() == 0, so this math
  // works.
  return byte_offset() + byte_length() > buffer_byte_length;

resize on a resizable buffer is not a reallocation; it is a permission change on already-reserved pages, plus a length update:

cpp
// src/objects/backing-store.cc:613
// Commit already reserved memory (for RAB backing stores (not shared)).
BackingStore::ResizeOrGrowResult BackingStore::ResizeInPlace(
    Isolate* isolate, size_t new_byte_length) {

Shrinking is more interesting than growing, because the bytes past the new end must be zeroed before the pages are decommitted - otherwise a later grow would expose stale data:

cpp
// src/objects/backing-store.cc:631
  if (new_byte_length < byte_length_) {
    // Zero the memory so that in case the buffer is grown later, we have
    // zeroed the contents already. This is especially needed for the portion of
    // the memory we're not going to decommit below (since it belongs to a
    // committed page). In addition, we don't rely on all platforms always
    // zeroing decommitted-then-recommitted memory, but zero the memory
    // explicitly here.
    memset(reinterpret_cast<uint8_t*>(buffer_start_) + new_byte_length, 0,
           byte_length_ - new_byte_length);

Shared buffers (GSAB) grow with a separate function, because several threads can call grow concurrently and the length is read atomically:

cpp
// src/objects/backing-store.cc:682
// Commit already reserved memory (for GSAB backing stores (shared)).
BackingStore::ResizeOrGrowResult BackingStore::GrowInPlace(
    Isolate* isolate, size_t new_byte_length) {

with an explicit race-handling rule:

cpp
// src/objects/backing-store.cc:700
  // See comment in GrowWasmMemoryInPlace.
  // GrowableSharedArrayBuffer.prototype.grow can be called from several
  // threads. If two threads try to grow() in a racy way, the spec allows the
  // larger grow to throw also if the smaller grow succeeds first. The
  // implementation below doesn't throw in that case - instead, it retries and
  // succeeds. If the larger grow finishes first though, the smaller grow must
  // throw.
  size_t old_byte_length = byte_length_.load(std::memory_order_seq_cst);

The detach key

One more mechanism is worth mentioning, because it is a security feature rather than a performance one. A buffer can carry a detach key, and detaching with the wrong key is a TypeError:

cpp
// src/objects/js-array-buffer.cc:127
Maybe<bool> JSArrayBuffer::Detach(DirectHandle<JSArrayBuffer> buffer,
                                  bool force_for_wasm_memory,
                                  DirectHandle<Object> maybe_key) {
  Isolate* const isolate = Isolate::Current();

  bool key_mismatch = false;

  auto key = buffer->DetachKey(isolate);
  if (!IsUndefined(key)) {
    key_mismatch =
        maybe_key.is_null() || !Object::StrictEquals(*maybe_key, key);
  } else {

The point is that an embedder can hand out a buffer but only allow the holder of the key to detach it, so a confused-deputy attack cannot invalidate a buffer somebody else is using. A buffer with a detach key also cannot be transferred:

cpp
// src/builtins/builtins-arraybuffer.cc:719
  // 8. If arrayBuffer.[[ArrayBufferDetachKey]] is not undefined, throw a
  //     TypeError exception.
  if (!IsUndefined(array_buffer->DetachKey(isolate)) ||
      !array_buffer->is_detachable()) {
    THROW_NEW_ERROR_RETURN_FAILURE(
        isolate,

Trade-offs

What it buys. Zero-copy views over any memory the embedder can produce. The ArrayBuffer/TypedArray split means the same bytes can be seen as Int8Array, Float64Array and BigUint64Array at once, with no copying. Transfer without copying means you can hand a buffer to a worker with no data movement. And because the raw pointer lives outside the V8 heap, the GC never has to move or trace it.

What it costs.

  • The buffer is not the data. Every dereference of a typed array goes through the buffer to find the backing store, then through the extension to find the BackingStore, then through the shared pointer to find the memory. V8 caches aggressively - DataPtr() is a couple of loads - but a typed array access is not a single load from a fixed address.
  • Detachment is a global event. Detaching one buffer invalidates the detaching protector, which deoptimizes every optimized function that touched any typed array. In a program that detaches buffers in a hot loop, this is expensive.
  • Resizable buffers reserve their maximum up front. A new ArrayBuffer(0, {maxByteLength: 1 << 30}) reserves a gigabyte of address space even if it never grows. The pages are not committed, so it is cheap on most systems, but it is not free.
  • Zero-copy transfer only works in the easy case. Same length, fixed length, not shared, no detach key. Otherwise you get a memcpy.
  • The whole external memory accounting is approximate. The sweeper updates the external memory counters with adjustments for concurrent resizing and detaching, because the exact byte count changes under it.

The bottom line

The mental model: an ArrayBuffer is a heap object that owns a resource outside the heap; a TypedArray is a heap object that describes how to interpret part of that resource.

The consequences follow from that split. The bytes can be shared between threads (SharedArrayBuffer), resized without reallocating (resizable buffers), moved between threads without copying (transfer), or invalidated outright (detach) - and the heap objects that describe them stay small, movable and cheap for the GC to trace.

The second mental model: detachment is a compiler event, not just a runtime one. The was_detached bit is the runtime half, but the protector is the half that matters for performance, because it is what makes every optimized function that reads a typed array deoptimize the moment the buffer goes away.

The thing people get wrong

The common belief is that detaching a buffer “frees the memory”. It does not - the memory is freed by the BackingStore’s destructor when the last std::shared_ptr goes away, which may be much later, and which for a resizable buffer means the reservation is released, not the committed pages.

The second belief is that ArrayBuffer.prototype.transfer copies. For the common case - same length, fixed length, not shared - it does not: it moves the BackingStore and detaches the old buffer. The copy only happens when the lengths differ.

The third is that a typed array is “an array with a different element type”. It is not - it is a view, and the buffer can be resized or detached underneath it, which is why V8 has to distinguish length-tracking views (whose length lives in the buffer, and so can never be stale) from non-length-tracking ones (whose length lives in the view, and which can go out of bounds).

V8InternalsTypedarraysMemorySandbox