Skip to content
Node.js nd buffers 4 min read

Manipulating Buffers

Once you have a Buffer, the real work begins: pulling fields out of a binary payload, splicing chunks together, or assembling a frame to send over the wire. Node’s Buffer API gives you a rich set of methods for slicing views, copying bytes, joining buffers, and reading or writing numbers in a specific endianness. Understanding which operations share memory and which allocate fresh storage is essential to avoiding subtle bugs and unnecessary copies.

Slicing without copying

buf.subarray(start, end) returns a new Buffer that points at the same underlying memory as the original — no bytes are copied. This makes it cheap, but it also means mutations bleed in both directions. The older buf.slice() is an alias with identical behaviour; prefer subarray, since slice on Buffer does not match Array.prototype.slice (which copies) and that mismatch trips people up.

import { Buffer } from 'node:buffer';

const original = Buffer.from('HELLO WORLD');
const view = original.subarray(0, 5);

console.log(view.toString());

// Mutating the view writes through to the original's memory:
view[0] = 0x68; // 'h'
console.log(original.toString());

Output:

HELLO
hELLO WORLD

Because subarray/slice share memory, never assume a sliced buffer is an independent copy. If you need isolation, copy the bytes explicitly with Buffer.from(view) or buf.copy.

Copying bytes into another buffer

source.copy(target, targetStart, sourceStart, sourceEnd) copies a region from one buffer into another, overwriting the target in place. It returns the number of bytes written and never allocates — the target must already be large enough. This is the right tool when you are filling a pre-allocated frame.

import { Buffer } from 'node:buffer';

const source = Buffer.from('binary');
const target = Buffer.alloc(10, 0x2d); // ten '-' bytes

const written = source.copy(target, 2);

console.log(written);
console.log(target.toString());

Output:

6
--binary--

To make a fully independent duplicate of a buffer, Buffer.from(buf) allocates new memory and copies the contents — unlike subarray.

Concatenating buffers

Buffer.concat(list, totalLength?) allocates a single new buffer and copies each source buffer into it in order. This is how you reassemble a payload collected from multiple stream data events. Passing the optional totalLength lets Node skip a length-summing pass and is a small optimization when you already know the size.

import { Buffer } from 'node:buffer';

const chunks = [
  Buffer.from('Node.js '),
  Buffer.from('buffers '),
  Buffer.from('rock'),
];

const joined = Buffer.concat(chunks);
console.log(joined.toString());
console.log(joined.length);

Output:

Node.js buffers rock
20

Writing numbers

Buffers store raw bytes, so writing a multi-byte integer requires choosing a byte order (endianness). The writeInt32BE / writeInt32LE family writes a value at a given offset and returns the offset just past the bytes written. BE is big-endian (most significant byte first), the convention for most network protocols; LE is little-endian, used by x86 hardware and many file formats.

import { Buffer } from 'node:buffer';

const buf = Buffer.alloc(8);

buf.writeInt32BE(305419896, 0); // 0x12345678 big-endian
buf.writeInt32LE(305419896, 4); // same value little-endian

console.log(buf.toString('hex'));

Output:

1234567878563412

Notice the same number produces reversed byte sequences depending on endianness — picking the wrong one is a classic source of corrupted binary data.

Reading typed values

Reading mirrors writing: pick the method matching the value’s size, sign, and byte order. These methods return a JavaScript number (or BigInt for the 64-bit variants).

MethodReadsBytes
readUInt8(offset)Unsigned 8-bit1
readInt16BE(offset)Signed 16-bit, big-endian2
readUInt32LE(offset)Unsigned 32-bit, little-endian4
readBigInt64BE(offset)Signed 64-bit, big-endian8
readFloatBE(offset)32-bit float, big-endian4
readDoubleLE(offset)64-bit double, little-endian8
import { Buffer } from 'node:buffer';

const frame = Buffer.from([0x00, 0x2a, 0x00, 0x00, 0x04, 0xd2]);

const messageType = frame.readUInt16BE(0); // 0x002a
const payloadSize = frame.readUInt32BE(2); // 0x000004d2

console.log(messageType, payloadSize);

Output:

42 1234

Filling a buffer

buf.fill(value, start?, end?) repeats a value across a region of the buffer, in place. It accepts a byte, a string, or another buffer, and is the idiomatic way to zero out or pad storage. Buffer.alloc(size, fill) is shorthand for allocating and filling in one step.

import { Buffer } from 'node:buffer';

const buf = Buffer.alloc(8);

buf.fill(0xff);          // all bytes
buf.fill(0x00, 2, 6);    // zero the middle four

console.log(buf.toString('hex'));

const dots = Buffer.alloc(5).fill('.');
console.log(dots.toString());

Output:

ffff00000000ffff
.....

Best Practices

  • Remember that subarray/slice share memory with the source; copy with Buffer.from(buf) when you need an independent value.
  • Pre-allocate a target with Buffer.alloc and use copy when you already know the output size, instead of repeatedly concatenating.
  • Pass totalLength to Buffer.concat when the size is known to skip the extra length-summing pass.
  • Always be explicit about endianness — use BE methods for network protocols and LE for x86-native formats, and document which a binary format expects.
  • Match read/write method width and signedness to the field exactly; reading a signed value as unsigned (or vice versa) silently corrupts data.
  • Validate offsets against buf.length before reading untrusted input — out-of-range access throws a RangeError.
Last updated June 14, 2026
Was this helpful?