Node.js Buffer Operations: Views, Copies, and Memory Ownership
Buffer work is byte ownership work. A read can decode bytes into text. A write can encode text into bytes. A view can borrow existing memory. A copy can give a byte range its own lifetime. Good Buffer code keeps those decisions visible, because the runtime will preserve exactly the ownership relationship your code creates.
Working With Buffer Data
Encoding is the split where bytes become text and text becomes bytes. buf.toString() decodes bytes. Buffer.from(text) encodes text. Once you stay on the byte side of that split, the next question is whether an operation shares backing memory or creates independent storage.
That question shows up quickly because Buffer.slice() does not behave like Array.prototype.slice(). Array slice() creates a new array. Buffer slice() creates a view over the same bytes as the original Buffer. That behavior enables zero-copy parsing and routing, but it is also the source of many retention and mutation bugs.
buf.slice() is deprecated in favor of buf.subarray(). However, understanding its behavior remains useful for maintaining legacy codebases and comprehending view mechanics.
The split is direct: slice() and subarray() create views; Buffer.copy() and Buffer.from(buffer) copy the exposed byte range. Views let you process large payloads without copying their bytes. The same views can also keep much larger backing stores alive after the code that created them has moved on. Because Buffers and TypedArrays sit on the same ArrayBuffer foundation, this ownership rule carries through parser code, worker-thread handoffs, and memory profiles.
A Gigabyte-Scale Memory Leak
This failure often starts with clean-looking code. A service receives large chunks, extracts a small identifier from each chunk, and stores the identifier for later work.
A small Buffer view can keep a much larger backing store alive. This is not a leak in Node.js; it is a lifetime bug in your code.
Suppose each incoming log batch or multipart upload chunk begins with a fixed-size session ID:
function getSessionIdView(logBuffer) {
if (logBuffer.length < 16) {
throw new RangeError("log buffer is missing the session id");
}
return logBuffer.subarray(0, 16);
}The returned value is a view. subarray() does not allocate 16 bytes for the session ID. It creates a small JavaScript wrapper that points at the same underlying ArrayBuffer as logBuffer, with its own byte offset and length. Legacy slice() has the same view behavior.
If that view is used only inside the current synchronous operation, the arrangement is often exactly what you want. If it is stored in a cache, a map, or a batch queue, the backing memory for the whole incoming chunk stays reachable through the view. Even though your application only cares about 16 bytes, the runtime cannot free the larger buffer while a live view still references its backing store.

Figure 2.1 — A retained view keeps the parent backing store reachable, while a copied range can outlive the source without retaining the larger allocation.
Heap snapshots can make this look strange. You may see many tiny Buffer objects retaining much larger amounts of external memory. The profiler is not broken. The small wrappers are alive, and through them the backing stores are alive too.
You are not retaining a few bytes. You are retaining the much larger allocation behind each request. Multiply that by thousands of stored views, and RSS can climb far beyond what the visible JavaScript object sizes suggest. The root cause is one method call whose ownership behavior was misunderstood.
The underlying constraint is simple: a Buffer object is a JavaScript object, but the bytes it exposes are backed by ArrayBuffer memory that Node tracks separately from ordinary JavaScript heap usage.
Understanding Buffer Memory Architecture
V8's garbage collector tracks JavaScript objects. Node's binary payload memory is accounted for differently, usually visible through process.memoryUsage().external and process.memoryUsage().arrayBuffers.
The Buffer object you use in JavaScript is a lightweight handle that lives on the V8 heap. That handle refers to the backing memory where the bytes live. This two-part system is what makes Buffers useful for binary I/O: Node can pass payloads to native code without first converting them into JavaScript strings or arrays.
The same split creates the retention hazard. If your code keeps the handle alive through a closure, cache, or long-lived object, the backing memory remains reachable too. You are not retaining only the JavaScript object; you are retaining the bytes that object exposes.
The 8KB Internal Buffer Pool
As covered in the allocation chapter, Node uses an internal allocation pool for some smaller buffers. The default Buffer.poolSize is 8KB. The pool may be used by Buffer.allocUnsafe(), Buffer.from(array), Buffer.from(string), and Buffer.concat() when the requested size is at most half the pool size.
Buffer.alloc() is the important exception. It never uses the internal pool. It returns initialized memory, which avoids exposing old bytes at the cost of extra work. Buffer.allocUnsafe() can be faster, but its returned bytes are uninitialized and may contain old data from previously used memory until you overwrite them.
Views and References with slice, subarray, and Buffer.from
With the memory model in place, the ownership rules come down to three APIs: Buffer.slice(), Buffer.subarray(), and Buffer.from() when used with another Buffer or ArrayBuffer.
slice() causes trouble because JavaScript arrays train the wrong reflex. Array.prototype.slice() creates a shallow copy. You slice an array, get a new array, and can modify one without affecting the other. Buffers do not follow that rule.
Buffer.prototype.slice() does not create a copy. It creates a view: a new Buffer object that points to the same underlying bytes as the original buffer.
Use a 50MB buffer as the parent payload:
const massiveBuffer = Buffer.alloc(50 * 1024 * 1024);
massiveBuffer.write("USER_ID:12345|REST_OF_DATA...");Buffer.alloc() returns initialized Buffer memory. For large buffers, the exact operating-system allocation path depends on Node, V8, libc, and the operating system, so the portable contract is simpler: you get a Buffer of the requested size, filled with zeroes unless you pass a fill value. The write() call then encodes the JavaScript string into bytes in that Buffer. ASCII characters map to one byte each; multi-byte UTF-8 characters require more bytes.
Now take a view over the bytes that contain the user ID:
const userIdView = massiveBuffer.subarray(8, 13);
console.log(userIdView.toString()); // 12345No payload bytes were copied. Node created a small wrapper with a different offset and length over the same backing store. A write through the view writes through to the parent allocation:
userIdView.write("99999");The string "99999" is encoded as bytes and written into the shared backing memory. There is no copy-on-write mechanism, no protection, and no warning.
console.log(massiveBuffer.toString("utf-8", 0, 20));
// USER_ID:99999|REST_OChanging userIdView changed the parent buffer because both objects expose overlapping bytes in the same ArrayBuffer. A modification through either reference is visible through the other.
In current Node.js versions, Buffer.prototype.slice() and Buffer.prototype.subarray() both create views into the same memory, not copies. Node marks buf.slice() as deprecated because it is not compatible with TypedArray.prototype.slice(), which creates a copy. Prefer subarray() in new code so readers do not confuse Buffer slicing with copying TypedArray.prototype.slice().
The same sharing is visible in a smaller example:
const mainBuffer = Buffer.from([1, 2, 3, 4, 5]);
const sub = mainBuffer.subarray(1, 3);sub has its own offset and length, but no new memory was allocated for the bytes [2, 3].
sub[0] = 99;
console.log(mainBuffer); // <Buffer 01 63 03 04 05>The third API, Buffer.from(), changes behavior depending on the input type:
| Input | Ownership behavior |
|---|---|
Buffer.from(string) | Encodes the string into a new Buffer byte range. |
Buffer.from(array) | Copies the numeric byte values into a new Buffer byte range. |
Buffer.from(arrayBuffer) | Creates a view that shares memory with the provided ArrayBuffer. |
Buffer.from(buffer) | Copies the exposed bytes from the source Buffer into a new Buffer byte range. |
Buffer.from(arrayBuffer) creates a view, but Buffer.from(buffer) copies the exposed bytes. Small copied Buffers may still be allocated inside Node's shared internal pool; the copy is independent by byte range, not necessarily by unique ArrayBuffer backing store.
That difference is a common source of bugs. If a helper accepts either an ArrayBuffer or a Buffer, normalize the contract explicitly instead of assuming Buffer.from(value) always has the same ownership behavior.
Zero-Copy Operations
"Zero-copy" is useful shorthand, but it can hide the ownership trade-off. A zero-copy Buffer operation means the data payload is not copied. It does not mean nothing was allocated. The view itself is still a JavaScript object, and that object still has a lifetime.
Here is a runnable benchmark shape you can use to compare view creation with copying on your own machine. The setup creates one large source buffer and chooses a fixed 1KB range inside it:
import { performance } from "node:perf_hooks";
const largeBuffer = Buffer.alloc(10 * 1024 * 1024, 1);
const chunkSize = 1024;
const startOffset = 5000;
const iterations = 200_000;
let sink = 0;The helper warms up the function, measures repeated calls, and prints microseconds per operation:
function bench(label, fn) {
for (let i = 0; i < 10_000; i++) sink += fn();
const start = performance.now();
for (let i = 0; i < iterations; i++) sink += fn();
const elapsed = performance.now() - start;
const micros = ((elapsed / iterations) * 1000).toFixed(3);
console.log(`${label}: ${micros} microseconds/op`);
}The two measured paths differ only in whether the selected range is copied:
bench("subarray view", () => {
return largeBuffer.subarray(startOffset, startOffset + chunkSize)[0];
});
bench("Buffer.from(view) copy", () => {
return Buffer.from(largeBuffer.subarray(startOffset, startOffset + chunkSize))[0];
});
console.log({ sink });On one local run with Node.js v24.15 on Linux x64, the view path measured around 0.048 microseconds/op, and the copy path measured around 0.276 microseconds/op. Treat those numbers as environment-specific. The stable lesson is the complexity: creating a view is effectively O(1), while copying is O(n) in the number of bytes copied.
The common mistake is to hear "zero-copy" and replace copies with views everywhere. That trades CPU cycles for memory management complexity. The view is fast because it borrows backing memory. As long as the view is alive, the backing store it references must stay alive too.
The correct optimization is not to use views everywhere. It is to understand when the cost of a small, explicit copy is lower than the memory cost of retaining a large backing store.
Buffers, TypedArrays, and the Memory They Share
Buffers are Node's built-in representation for binary data, but they do not exist alone. They are part of the TypedArray family, and that relationship is important when code crosses into Web APIs, workers, or custom binary parsers.
Since Node.js v3.0, the Buffer class has been a direct subclass of Uint8Array.
const buf = Buffer.from("hello");
console.log(buf instanceof Uint8Array); // trueThat subclassing is an interoperability detail. You can pass a Node Buffer to APIs that accept Uint8Array, including many Web-compatible APIs available in Node. A browser environment does not provide Node's Buffer global unless a tool or library supplies one.
The raw byte store underneath these objects is an ArrayBuffer. Buffer, Uint8Array, Int32Array, and the other typed-array classes are different views over that memory. A Buffer reads it as individual bytes. An Int32Array groups those bytes into 4-byte chunks and reads them as 32-bit numbers. That interop cuts both ways: overlapping views can corrupt fields without throwing.

Figure 2.2 — Typed-array and Buffer views are ranges over the same byte store; when ranges overlap, a write through one view changes what the other reads.
One detail is easy to miss: buf.buffer can be larger than the bytes exposed by buf. Small Buffers may sit inside Node's internal pool, so the ArrayBuffer returned by buf.buffer can contain unrelated bytes before or after the Buffer's own range. When you build a typed-array or DataView over a Buffer's backing store, pass buf.byteOffset and buf.length.
const buf = Buffer.from([0x01, 0x02]);
const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.length);Using only buf.buffer would address the whole backing store, not necessarily the Buffer's visible byte range.
Consider a 12-byte message from the network:
const messageArrayBuffer = new ArrayBuffer(12);Two views can safely cover separate ranges:
const stringView = Buffer.from(messageArrayBuffer, 4, 8);
stringView.write("CONFIRMD");
const intView = new Int32Array(messageArrayBuffer, 0, 1);
console.log("Initial integer value:", intView[0]); // 0An incorrect offset is enough to make those views overlap:
const buggyStringView = Buffer.from(messageArrayBuffer, 0, 8);
buggyStringView.write("CANCELED");The code runs with no exceptions, but writing the string overwrites the integer bytes. The bytes for "CANC" ([0x43, 0x41, 0x4e, 0x43]) now occupy the same memory where the number used to be.
console.log("Corrupted integer value:", intView[0]);
// 1129201987 on little-endianThe exact integer depends on platform endianness. On common little-endian systems, the byte sequence 43 41 4e 43 is interpreted as 1129201987. When you create multiple views over a single ArrayBuffer, you are responsible for every offset and every length; Node does not check whether your views overlap.
When Views Share Memory (and When They Don't)
The common Buffer and TypedArray operations fall into two ownership buckets: view operations share memory, and copy operations create an independent byte range.
| Creates views | Creates copies |
|---|---|
Buffer.prototype.slice(start, end) | Buffer.alloc(size) |
Buffer.prototype.subarray(start, end) | Buffer.from(string) |
new Uint8Array(arrayBuffer, byteOffset, length) and other TypedArray constructors over an ArrayBuffer | Buffer.from(array) |
Buffer.from(arrayBuffer, byteOffset, length) | Buffer.from(buffer) |
Buffer.prototype.copy() into an existing buffer | |
Uint8Array.prototype.slice(start, end) |
These zero-copy tools are useful for temporary sub-sections of existing data. The key word is temporary. If a view is short-lived and goes out of scope quickly, you get the performance benefit without much retention risk. If it escapes into a longer-lived structure, it carries the source backing store with it.
Because Buffer is a Uint8Array, the slice() exception is important. If you call the Uint8Array prototype's slice method directly on a buffer with Uint8Array.prototype.slice.call(buf, ...), you get a copy instead of a view. That is one reason new Buffer code should prefer subarray() when the intent is a view.
The lifetime issue is easy to see when reading a large file but keeping only a small piece of metadata:
import { readFileSync } from "node:fs";
const videoBuffer = readFileSync("large-video.bin");readFileSync() blocks the event loop while Node reads the file and returns a Buffer containing the file contents. The filename is illustrative; do not use this pattern in a request path for large files. It is here to make the ownership issue concrete.
const metadataView = videoBuffer.subarray(0, 1024);This creates a small Buffer view that holds a reference to the same backing memory as videoBuffer. If that view is passed to a cache, a global variable, or any long-lived data structure, the larger backing store remains reachable for as long as the metadata view remains reachable.
If the metadata needs to survive, make the lifetime explicit:
const metadataCopy = Buffer.from(videoBuffer.subarray(0, 1024));Buffer.from(buffer) copies the bytes exposed by the source Buffer into an independent byte range. metadataCopy no longer references the original file buffer, so keeping the metadata does not keep the full file contents alive.
The useful question is lifetime-based: is this data short-lived or long-lived? For temporary, in-function processing, views are usually appropriate. For data that needs to be stored, cached, or passed between different parts of your application, an explicit copy gives the byte range independent ownership.
Copy Semantics and Buffer.copy()
Sometimes you need a copy. The primary tool for copying into an existing destination is Buffer.prototype.copy(), whose signature is buf.copy(targetBuffer, targetStart, sourceStart, sourceEnd).
copy() writes into an existing targetBuffer, so you allocate the destination before calling it:
const source = Buffer.from("abcdefghijklmnopqrstuvwxyz");
const target = Buffer.alloc(10);Buffer.from(string) encodes the 26-character alphabet into 26 bytes of UTF-8. Since these are ASCII characters, each character is one byte. This small Buffer.from(string) allocation may use Node's internal pool; Buffer.alloc(10) does not use that pool.
source.copy(target, 0, 0, 10);
console.log(target.toString()); // 'abcdefghij'Those bytes are duplicated into target. Later changes to source do not affect target, and later changes to target do not affect source.
source.copy(target, 3, 10, 15);
console.log(target.toString()); // 'abcklmnoij'For copying data between buffers, Buffer.copy() is the idiomatic API and avoids manual byte-by-byte JavaScript loops. The work still scales with the number of bytes copied.
Buffer.from(buffer) is the concise copy API:
const original = Buffer.from("This is the original buffer");
const clone = Buffer.from(original);Buffer.from(buffer) copies the bytes exposed by buffer. The string in this example is 27 ASCII bytes, so the clone has length 27. Writes to the clone do not affect the original. For small Buffers, Node may allocate both byte ranges inside the same internal pool ArrayBuffer; the guarantee is independent exposed bytes, not a unique backing store object.
clone.write("That");
console.log(original.toString()); // 'This is the original buffer'
console.log(clone.toString()); // 'That is the original buffer'Use Buffer.from(buffer) when you want the allocation and copy in one expression. Use Buffer.copy() when you need to reuse an existing destination buffer to control allocation behavior.
Use the same rule from the leak example: if a small piece of data needs to outlive a larger source buffer, give it independent storage. Buffer.copy() can do that with a pre-allocated target. Buffer.from(source.subarray(start, end)) creates the trimmed copy in one line.
Instead of storing the view from the log parser, store a copy:
function getSessionId(logBuffer) {
if (logBuffer.length < 16) {
throw new RangeError("log buffer is missing the session id");
}
const sessionId = Buffer.from(logBuffer.subarray(0, 16));
return sessionId.toString("utf-8");
}sessionId is an independent byte range and no longer references logBuffer's backing memory. When logBuffer goes out of scope and no other views reference it, the larger backing store can be reclaimed.
For security-sensitive data, use Buffer.alloc() for new memory and Buffer.from(sourceBuffer) or Buffer.from(typedArrayView) when you intend to copy an existing byte range. Do not treat Buffer.from(arrayBuffer) as a copy; it creates a view. Only use Buffer.allocUnsafe() when you fully overwrite the returned buffer before any read.
This change from view retention to an explicit copy does more work per message, but it gives the stored value the right lifetime.
Worker Threads and SharedArrayBuffer
Worker threads add another ownership handoff. Node can move or clone data between workers, and it can also share memory explicitly.
A regular ArrayBuffer is not automatically shared. If you send it to a worker without a transfer list, the data is cloned. If you include the ArrayBuffer in the transfer list, ownership moves to the receiver and the sender's ArrayBuffer becomes detached. A SharedArrayBuffer is different: both threads can create views over the same shared data block.
In Node.js worker threads, use SharedArrayBuffer only when you are deliberately sharing mutable memory. Use Atomics for cross-thread coordination; regular indexed reads and writes are not a synchronization protocol.
The view rules are the same as before. A TypedArray or Buffer view over a SharedArrayBuffer does not own independent bytes. It exposes shared bytes, and writes from one thread can be observed by another thread.
Main-thread excerpt:
const sab = new SharedArrayBuffer(4);
const mainView = new Int32Array(sab);
Atomics.store(mainView, 0, 123);
// worker.postMessage({ sab });Worker excerpt:
import { parentPort } from "node:worker_threads";
parentPort.on("message", ({ sab }) => {
const workerView = new Int32Array(sab);
console.log(Atomics.load(workerView, 0)); // 123
Atomics.store(workerView, 0, 456);
parentPort.postMessage("done");
});Atomics provides atomic reads, writes, and read-modify-write operations for integer typed-array views backed by shared memory. It does not make a whole protocol correct by itself; it gives you the primitives to build one.
Be careful with Buffers and transfer lists. In Node v24, backing stores used by the internal Buffer pool are marked untransferable. If you put a pooled Buffer's buffer in a transfer list, postMessage() throws DataCloneError. If you send it without a transfer list, the data is cloned, and that clone can include the whole 8KB pool rather than the tiny Buffer range you meant to send.
Buffer.alloc() returns initialized memory outside the internal pool, so its ArrayBuffer can be transferred and detached in current Node. Small Buffers created through pooled paths such as Buffer.from("x") have different ownership. If a worker handoff needs only a few bytes, copy the exact range first and send that range deliberately.
The chapter's main point still holds across this split: views do not own independent bytes. With SharedArrayBuffer, that rule extends across threads.
Memory Retention and Garbage Collection
Memory retention follows reachability. In a garbage-collected language like JavaScript, an object is kept in memory as long as there is a reachable reference to it from the root set, such as the global object, active stack frames, closures, or live data structures.
For a Buffer view, the relationship has two parts:
| Part | What it is |
|---|---|
| View object | The new Buffer instance, with its own offset and length. |
| Backing store | The underlying ArrayBuffer or external memory containing the bytes. |
A view keeps the backing store reachable. The original Buffer wrapper object may not be the object that explains retention in a heap snapshot; the retained resource is the larger backing memory. The runtime cannot know that your application only cares about 16 bytes out of a 50MB allocation.
This is why the heap snapshot from the earlier leak can look odd. The profiler correctly identifies that the small Buffer objects have small shallow sizes, but it also reports retained size separately.
| Size | Meaning |
|---|---|
| Shallow size | The size of the object itself. For a Buffer view, this is the wrapper object, not the whole payload. |
| Retained size | The size of memory kept alive solely because this object exists. For long-lived views, this can include much larger backing memory. |
The fix is to sever the link between the small byte range you need and the larger backing store. The way to do that is with a copy.
function createView(parent) {
return parent.subarray(0, 10);
}This function returns a view that maintains a reference to parent's backing memory. If parent is 10MB, retaining the 10-byte view can retain the 10MB backing store.
function createCopy(parent) {
return Buffer.from(parent.subarray(0, 10));
}The Buffer.from(buffer) call copies the bytes exposed by the temporary view. Small copies may be backed by Node's internal pool, but the returned Buffer no longer references the large parent backing store. This pattern, Buffer.from(buf.subarray(...)), is a common way to create a trimmed copy of a small section of a large buffer.
Binary Protocol Parsing with Views
Binary protocols appear wherever wire size, parse cost, or fixed field layout is important. A protocol defines a strict layout of data in a sequence of bytes. A message might use this layout:
| Range | Field |
|---|---|
| Bytes 0-1 | Message Type (Uint16) |
| Bytes 2-3 | Message Length (Uint16) |
| Byte 4 | Flags (Uint8) |
| Bytes 5-20 | Session ID, a 16-byte binary value |
| Bytes 21-end | Payload, raw bytes |
A naive parser can create temporary views even for fields that can be read directly by offset:
function parseMessageWithTemporaryViews(buffer) {
if (buffer.length < 21) throw new RangeError("message is shorter than the fixed header");
const messageType = buffer.subarray(0, 2).readUInt16BE(0);
const messageLength = buffer.subarray(2, 4).readUInt16BE(0);
const flags = buffer.subarray(4, 5).readUInt8(0);
const sessionIdHex = buffer.subarray(5, 21).toString("hex");
const payload = buffer.subarray(21);
return { messageType, messageLength, flags, sessionIdHex, payload };
}Each primitive field read creates a temporary Buffer view just to call a numeric read method. The code works, but it creates five Buffer views for every message. At high message rates, those wrapper allocations add avoidable garbage-collection pressure.
The above pattern returns a payload view. If callers retain those views for about a second while processing 1000 messages/sec with 1MB payloads, they can retain roughly 1000MB of backing memory.
The tighter parser uses offset-based read methods directly on the main buffer, then returns views only for fields whose bytes the caller may need:
function parseMessageWithViews(buffer) {
if (buffer.length < 21) throw new RangeError("message is shorter than the fixed header");
const messageType = buffer.readUInt16BE(0);
const messageLength = buffer.readUInt16BE(2);
const flags = buffer.readUInt8(4);
const sessionIdView = buffer.subarray(5, 21);
const payloadView = buffer.subarray(21);
return { messageType, messageLength, flags, sessionIdView, payloadView };
}This version creates no intermediate views for the primitive number fields. It creates two views for the session ID and payload, and no data is duplicated.
This zero-copy version returns views that retain the message backing store. Document this clearly: callers must copy returned views if they need to store them beyond the immediate processing scope.
Use a view because the processing is temporary. If the sessionIdView or payloadView needs to be stored, copied across an ownership handoff, or kept after the source message should be releasable, copy it before storing. A parsing function can return views as a deliberate contract; the consumer then decides whether the data is short-lived enough to use directly or long-lived enough to copy.
Platform Endianness and TypedArray Views
Endianness is the byte order for a multi-byte number, such as a 16-bit or 32-bit integer.
| Order | Meaning |
|---|---|
| Big-endian (BE) | The most significant byte comes first. Network protocols often use this order, so 0x12345678 is stored as 12 34 56 78. |
| Little-endian (LE) | The least significant byte comes first. Most modern CPUs, including Intel and AMD x86-64, use this order, so 0x12345678 is stored as 78 56 34 12. |
Forgetting about endianness produces incorrect values when reading binary protocols. Node.js Buffers provide explicit methods for this: readUInt16BE, readUInt16LE, writeInt32BE, and the related read/write methods. These are the clearest option when you know the exact byte order of the data you're parsing.
TypedArray views add one more constraint: native byte order. TypedArrays such as Int16Array and Float64Array read and write data using the host system's native endianness. On common x86-64 systems, that is little-endian. If you create an Int16Array view over a buffer that contains big-endian network data, you will read the wrong value.
const networkBuffer = Buffer.from([0x01, 0x02]);
console.log(networkBuffer.readUInt16BE(0)); // 258The readUInt16BE() method explicitly handles byte order. It interprets byte 0 as the high byte and byte 1 as the low byte, regardless of the platform's native endianness.
const int16View = new Int16Array(networkBuffer.buffer, networkBuffer.byteOffset, 1);
console.log(int16View[0]); // 513 on little-endianTypedArray views use platform endianness. Network protocols often use big-endian. Use Buffer's BE/LE methods or DataView with explicit endianness when parsing externally defined binary formats.
Use DataView when generic ArrayBuffer code needs explicit byte order. A DataView reads and writes values in an ArrayBuffer and lets you specify endianness for each operation.
const arrayBuffer = new ArrayBuffer(4);
const dataView = new DataView(arrayBuffer);
dataView.setInt32(0, 123456789, false);The setInt32() call with false writes the bytes as [0x07, 0x5b, 0xcd, 0x15], most significant byte first. DataView handles the byte ordering according to the boolean argument you pass.
console.log(dataView.getInt32(0, true)); // 365779719
console.log(dataView.getInt32(0, false)); // 123456789When in doubt, be explicit. Use Buffer's BE/LE methods or use a DataView.
Production Patterns for Zero-Copy
These patterns keep ownership decisions visible in Buffer-heavy code.
Pattern 1: The Temporary View for Synchronous Processing
The narrowest safe use of zero-copy is processing a chunk of a larger buffer within a single synchronous scope:
function processChunk(largeBuffer, offset, length, calculate) {
if (offset < 0 || length < 0 || offset + length > largeBuffer.length) {
throw new RangeError("chunk range is outside the buffer");
}
const view = largeBuffer.subarray(offset, offset + length);
return calculate(view);
}Use this pattern only if calculate() consumes the view synchronously and does not store or return it. If the callback returns the view, puts it into a collection, or starts async work that uses it later, the view has escaped and can retain or observe the parent backing store. If the data needs to be stored long-term or used by code with a different lifetime, copy it instead.
Pattern 2: The Defensive Copy for Asynchronous Operations and Storage
Any time buffer data crosses an asynchronous handoff or is stored in a collection, check its ownership. A retained view keeps the source backing store alive, and the original owner might also mutate or reuse the source buffer.
const longLivedCache = new Map();
function processAndCache(dataBuffer) {
if (dataBuffer.length < 16) throw new RangeError("data buffer is missing the key");
const key = dataBuffer.subarray(0, 16);
const value = dataBuffer.subarray(16);
longLivedCache.set(key.toString("hex"), Buffer.from(value));
}The views are temporary parsing aids. The moment value goes into longLivedCache, it becomes an independent copy. The key becomes a string, which is also independent of the original Buffer bytes. The cache entry is now self-contained and does not retain a much larger dataBuffer.
Pattern 3: The Parser Protocol (Views out, Copies in)
Library code often returns views and makes that contract explicit. Returned views reflect later mutations to the original buffer and retain its backing memory, so the caller must decide whether to copy.
/**
* Parses a message header from a buffer.
* WARNING: Returned values are views; copy before storing.
* @returns {{id: Buffer, body: Buffer}} Views for id and body.
*/
function parseHeader(buffer) {
if (buffer.length < 8) throw new RangeError("buffer is shorter than the header");
return { id: buffer.subarray(0, 8), body: buffer.subarray(8) };
}This function contract is important. The comment warns that returned values are views, which leaves the memory management decision with the caller:
const rawMessage = Buffer.alloc(32);
const { id, body } = parseHeader(rawMessage);
const savedId = Buffer.from(id);
console.log(body.length);Consumers who can handle the data immediately avoid copies. Consumers who need to store data make ownership explicit.
Debugging Memory Issues with Views
When you suspect a view-related memory leak, your primary tool is the heap snapshot. You can generate snapshots using Chrome DevTools for Node.js, the built-in node:v8 writeHeapSnapshot() API, or packages such as heapdump.
The process is usually:
- Take a heap snapshot when your application is in a stable, low-memory state.
- Apply a load to your application that you suspect triggers the leak.
- Take a second heap snapshot.
- Take a third snapshot after some more time to confirm the growth trend.
In the snapshot viewer, use the Comparison view to see what objects were allocated between snapshots. For Buffer retention bugs, look for many small Buffer views retaining large backing stores.
Chrome DevTools with node --inspect-brk can inspect retained size and retaining paths. The exact labels vary by DevTools and V8 version, but the relationship to look for is stable: a small Buffer view keeping a much larger backing allocation reachable.
Another useful signal is process.memoryUsage(). In Node.js v13.9 and v12.17+, process.memoryUsage().arrayBuffers tracks memory allocated for ArrayBuffers and SharedArrayBuffers, including Node.js Buffers. This value is also included in external.
If heapUsed is stable but arrayBuffers, external, or rss keeps growing, look for retained Buffer views, large Buffers, or allocator fragmentation before assuming ordinary JavaScript object growth. external and rss may also stay high after objects become unreachable because allocator behavior is not the same as JavaScript reachability.
Metrics can tell you that memory is growing. Heap snapshots show the reference chain that keeps the backing memory alive.
Best Practices for Buffer Manipulation
Use this checklist when writing Buffer-heavy code:
- Use views for temporary, synchronous processing.
- Use explicit copies for data that is long-lived, cached, or handed to code with a different lifetime.
- Document functions that return views.
- Treat
slice()orsubarray()assigned to an object property, closure, cache, or module-level variable as a review point. - Profile memory behavior under representative load, not just functional correctness.
- Ask two questions for every zero-copy operation: how long will the view live, and how long should the source backing store live?
Memory Profiling Data
Use a small script when you want to demonstrate the retention behavior on your own machine. Run it with node --expose-gc buffer-retention.mjs so the script can request a GC between snapshots.
Start with the GC guard and formatting helper:
if (typeof global.gc !== "function") {
throw new Error("Run with: node --expose-gc buffer-retention.mjs");
}
function mb(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}The snapshot helper forces a collection, then prints the memory categories relevant to Buffer backing stores:
function snapshot(label) {
global.gc();
const memory = process.memoryUsage();
console.log(label, {
heapUsed: mb(memory.heapUsed),
arrayBuffers: mb(memory.arrayBuffers),
external: mb(memory.external),
rss: mb(memory.rss),
});
}The first allocation path keeps many views into one large Buffer:
function makeViews() {
let large = Buffer.alloc(50 * 1024 * 1024, 1);
const views = [];
for (let i = 0; i < 100_000; i++) views.push(large.subarray(0, 10));
large = null;
return views;
}The second path copies the same 10-byte range each time:
function makeCopies() {
let large = Buffer.alloc(50 * 1024 * 1024, 1);
const copies = [];
for (let i = 0; i < 100_000; i++) copies.push(Buffer.from(large.subarray(0, 10)));
large = null;
return copies;
}Running both paths shows how the retained memory differs:
snapshot("start");
let views = makeViews();
snapshot("views retained");
views = null;
snapshot("views released");
let copies = makeCopies();
snapshot("copies retained");
console.log("sample copy backing store bytes:", copies[0].buffer.byteLength);
copies = null;
snapshot("copies released");Sample output from Node.js v24.15 on Linux x64:
start {
heapUsed: '3.6 MB',
arrayBuffers: '0.0 MB',
external: '1.4 MB',
rss: '69.8 MB'
}
views retained {
heapUsed: '14.7 MB',
arrayBuffers: '50.0 MB',
external: '51.6 MB',
rss: '144.2 MB'
}
views released {
heapUsed: '3.9 MB',
arrayBuffers: '0.0 MB',
external: '51.6 MB',
rss: '95.4 MB'
}
copies retained {
heapUsed: '14.8 MB',
arrayBuffers: '1.5 MB',
external: '53.1 MB',
rss: '115.5 MB'
}
sample copy backing store bytes: 8192
copies released {
heapUsed: '4.0 MB',
arrayBuffers: '0.0 MB',
external: '3.1 MB',
rss: '112.5 MB'
}The exact numbers will vary. The important shape is stable: retained views keep the 50MB backing store visible in arrayBuffers; retained copies do not. Notice that the small copies in this run use pooled backing stores (8192 bytes), not one unique 10-byte ArrayBuffer per copy.
The Trade-Off
Copying everything makes ownership simpler, but it can waste CPU and memory in hot paths. Returning views is fast and allocation-friendly, but only when the view lifetime matches the source lifetime.
Shared backing memory is a mechanism with advantages and risks. When you create a view, you are making a promise that the view and the backing memory can safely have the same lifetime.
When you see const view = buf.subarray(0, 10), the review question is concrete: how long will this view live, and what backing memory does it keep alive? If those lifetimes do not match, make a copy.