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/sliceshare memory, never assume a sliced buffer is an independent copy. If you need isolation, copy the bytes explicitly withBuffer.from(view)orbuf.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).
| Method | Reads | Bytes |
|---|---|---|
readUInt8(offset) | Unsigned 8-bit | 1 |
readInt16BE(offset) | Signed 16-bit, big-endian | 2 |
readUInt32LE(offset) | Unsigned 32-bit, little-endian | 4 |
readBigInt64BE(offset) | Signed 64-bit, big-endian | 8 |
readFloatBE(offset) | 32-bit float, big-endian | 4 |
readDoubleLE(offset) | 64-bit double, little-endian | 8 |
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/sliceshare memory with the source; copy withBuffer.from(buf)when you need an independent value. - Pre-allocate a target with
Buffer.allocand usecopywhen you already know the output size, instead of repeatedly concatenating. - Pass
totalLengthtoBuffer.concatwhen the size is known to skip the extra length-summing pass. - Always be explicit about endianness — use
BEmethods for network protocols andLEfor 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.lengthbefore reading untrusted input — out-of-range access throws aRangeError.