The Node.js Buffer guide
By Flavio Copes
Learn how Node.js Buffer handles binary data, including encodings, safe allocation, slicing, copying, integers, ArrayBuffer, Uint8Array, and streams.
A Node.js Buffer represents a fixed-length sequence of bytes.
You meet buffers when you read files, receive network data, compress content, calculate hashes, or work with images and other binary formats.
A string represents text. A buffer represents bytes. Converting between them requires an encoding.
Let’s see how buffers work and where the surprising parts are.
Import Buffer explicitly
Buffer is available globally in Node.js, but the official documentation recommends importing it explicitly:
import { Buffer } from 'node:buffer'
This makes the dependency clear and avoids confusion in code that also runs in browsers or other JavaScript runtimes.
Buffer extends JavaScript’s Uint8Array. Each item is an integer from 0 through 255, representing one byte.
const data = Buffer.from([72, 101, 121, 33])
console.log(data) // <Buffer 48 65 79 21>
console.log(data.toString()) // Hey!
Node prints buffer bytes in hexadecimal. Decimal 72 is hexadecimal 48.
Bytes are not characters
JavaScript strings contain Unicode text. UTF-8 encodes that text as bytes.
ASCII characters use one byte in UTF-8:
const text = Buffer.from('coffee')
console.log(text.length) // 6
Other characters can use more:
const text = Buffer.from('caffè')
console.log(text.length) // 6
console.log('caffè'.length) // 5
The string has five JavaScript code units. Its UTF-8 representation has six bytes because è uses two.
Use Buffer.byteLength() when a protocol needs the byte length of a string:
const body = JSON.stringify({ drink: 'caffè' })
const length = Buffer.byteLength(body, 'utf8')
This matters for headers such as Content-Length. body.length counts JavaScript code units, not encoded bytes.
Create a buffer from a string
Buffer.from() encodes a string as UTF-8 by default:
const buffer = Buffer.from('Hello from Node')
Convert it back with toString():
console.log(buffer.toString('utf8'))
You can choose another encoding:
const buffer = Buffer.from('hello', 'utf8')
console.log(buffer.toString('hex'))
console.log(buffer.toString('base64'))
console.log(buffer.toString('base64url'))
This prints representations of the same bytes.
Common encoding names include:
utf8for textutf16lefor little-endian UTF-16latin1for one-byte character valueshexfor hexadecimal textbase64for standard Base64base64urlfor the URL-safe Base64 alphabet without padding on output
Hex and Base64 are not character sets. They turn binary data into text that is easier to store in JSON, URLs, or configuration.
Do not use Base64 as encryption. Anyone can decode it.
Decode Base64 and hex
Pass the source encoding to Buffer.from():
const encoded = 'SGVsbG8h'
const data = Buffer.from(encoded, 'base64')
console.log(data.toString('utf8')) // Hello!
Hex works the same way:
const data = Buffer.from('48656c6c6f21', 'hex')
console.log(data.toString()) // Hello!
Validate untrusted encoded input before relying on it. In particular, malformed hex strings can be decoded only up to the first invalid or incomplete byte instead of throwing the error you might expect.
Allocate an empty buffer safely
Use Buffer.alloc() when you need a buffer of a specific size:
const data = Buffer.alloc(1024)
This creates 1024 zero-filled bytes.
You can fill it with another value:
const separator = Buffer.alloc(4, 0xff)
Buffer.allocUnsafe() skips initialization:
const data = Buffer.allocUnsafe(1024)
It can be faster, but the memory may contain previous data. You must overwrite every byte before reading or exposing it.
My advice is to use Buffer.alloc() unless you have measured a real allocation bottleneck and can prove every byte is replaced.
Never send an unsafe buffer before filling it. Old process data can include secrets.
Read and change individual bytes
Access a buffer like an array:
const data = Buffer.from('Hey!')
console.log(data[0]) // 72
console.log(data[1]) // 101
Change a byte by assigning a number:
const data = Buffer.from('Hey!')
data[1] = 111
console.log(data.toString()) // Hoy!
Values are constrained to one byte. A buffer is not an array of arbitrary numbers.
You can iterate over it:
for (const byte of Buffer.from('Hey!')) {
console.log(byte)
}
Write text into an allocated buffer
The write() method encodes text into existing storage:
const data = Buffer.alloc(8)
const bytesWritten = data.write('caffè')
console.log(bytesWritten) // 6
console.log(data.toString('utf8', 0, bytesWritten)) // caffè
The return value is the number of bytes written.
If the buffer is too small, Node writes only the bytes that fit without writing a partial multibyte character:
const data = Buffer.alloc(4)
const bytesWritten = data.write('caffè')
console.log(data.toString('utf8', 0, bytesWritten)) // caff
Keep track of the meaningful length when a buffer is larger than its current content.
Slice and subarray share memory
This is a common source of bugs.
Buffer.subarray() creates a view over the same memory:
const original = Buffer.from('coffee')
const view = original.subarray(0, 3)
view[0] = 67
console.log(view.toString()) // Cof
console.log(original.toString()) // Coffee
Changing the view changes the original buffer.
Buffer.slice() also creates a view for historical compatibility. This differs from TypedArray.prototype.slice(), which creates a copy.
Prefer subarray() when you want shared memory because its name communicates that behavior across typed arrays.
To make an independent copy, pass the view to Buffer.from():
const original = Buffer.from('coffee')
const copy = Buffer.from(original.subarray(0, 3))
copy[0] = 67
console.log(copy.toString()) // Cof
console.log(original.toString()) // coffee
Choose deliberately between a view and a copy. A view avoids allocation, while a copy avoids unexpected shared changes.
Copy bytes into another buffer
Use copy() when you already have a destination:
const source = Buffer.from('espresso')
const destination = Buffer.alloc(8)
source.copy(destination, 0, 0, 8)
console.log(destination.toString()) // espresso
The arguments after the destination are destination start, source start, and source end.
For a complete copy, Buffer.from(source) is clearer.
Join several buffers
Buffer.concat() joins buffers into a new one:
const first = Buffer.from('Hello, ')
const second = Buffer.from('Flavio!')
const result = Buffer.concat([first, second])
console.log(result.toString())
This is useful when an API gives you several chunks and you genuinely need the complete value in memory.
Be careful with large streams. Repeatedly concatenating every chunk copies data and grows memory usage. Process the stream incrementally or pipe it to its destination when possible.
The Node.js streams guide explains that workflow.
Compare and search buffers
Use equals() to compare byte content:
const first = Buffer.from('hello')
const second = Buffer.from('hello')
console.log(first.equals(second)) // true
console.log(first === second) // false
=== compares object identity, not bytes.
Use compare() for sorting or ordered comparison:
const values = [Buffer.from('b'), Buffer.from('a')]
values.sort(Buffer.compare)
Buffers also provide includes(), indexOf(), and lastIndexOf():
const data = Buffer.from('one two three')
console.log(data.includes('two')) // true
console.log(data.indexOf('three')) // 8
The returned positions are byte offsets.
Serialize a buffer to JSON
JSON.stringify() cannot put raw bytes in JSON. A buffer uses a structured representation:
const data = Buffer.from('Hi')
console.log(JSON.stringify(data))
The result looks like this:
{"type":"Buffer","data":[72,105]}
You can rebuild it with:
const value = { type: 'Buffer', data: [72, 105] }
const data = Buffer.from(value.data)
For public JSON APIs, Base64 is usually more compact than an array of byte numbers:
const payload = {
content: data.toString('base64')
}
Document the encoding. A string of letters gives the receiver no way to know whether it contains UTF-8, Base64, hex, or an identifier.
Read and write numbers
Binary formats store numbers as bytes. Buffer provides methods for signed and unsigned integers, floating-point numbers, and big integers.
Write a 32-bit unsigned integer:
const data = Buffer.alloc(4)
data.writeUInt32BE(2026, 0)
console.log(data) // <Buffer 00 00 07 ea>
console.log(data.readUInt32BE(0)) // 2026
BE means big-endian: the most significant byte comes first.
Little-endian methods end in LE:
const data = Buffer.alloc(4)
data.writeUInt32LE(2026, 0)
console.log(data) // <Buffer ea 07 00 00>
The binary format tells you which byte order and number size to use. Guessing produces valid-looking but wrong values.
Node checks offsets and ranges. Writing a four-byte integer into a two-byte buffer throws an error.
Buffer, Uint8Array, and ArrayBuffer
A Buffer is a subclass of Uint8Array, so it has typed-array methods and properties.
Node.js APIs generally accept a plain Uint8Array where they accept a buffer. Buffer adds Node-specific encoding and binary helpers.
An ArrayBuffer is the underlying block of memory used by typed-array views.
You can create a buffer that shares an ArrayBuffer:
const arrayBuffer = new ArrayBuffer(4)
const bytes = new Uint8Array(arrayBuffer)
const buffer = Buffer.from(arrayBuffer)
bytes[0] = 72
console.log(buffer[0]) // 72
Both views point at the same memory.
When exposing a buffer’s underlying ArrayBuffer, remember that pooled buffers can occupy only part of it. Preserve the offset and length:
const view = new Uint8Array(
buffer.buffer,
buffer.byteOffset,
buffer.length
)
Ignoring byteOffset can expose unrelated bytes from the same allocation.
The official Buffer documentation describes the subtle differences between Buffer and typed arrays.
Buffers and cryptography
Node’s cryptography APIs accept buffers because hashes, keys, signatures, and ciphertext are bytes.
Hash a file buffer like this:
import { createHash } from 'node:crypto'
const digest = createHash('sha256')
.update(fileBuffer)
.digest('hex')
The digest is returned as hex only for display or storage. Internally the hash result is binary too.
Be careful when comparing secret values. buffer.equals() can return early and should not be used when timing differences matter. Node provides timingSafeEqual() for equal-length byte sequences:
import { timingSafeEqual } from 'node:crypto'
const valid = timingSafeEqual(received, expected)
Check lengths before calling it because different lengths throw. Also remember that constant-time byte comparison does not make the rest of an authentication flow constant-time.
Never convert arbitrary secret bytes to UTF-8. Use the representation required by the protocol, commonly hex or Base64.
Parse a small binary header
Buffer methods become especially useful when a format defines exact byte positions.
Imagine a four-byte header:
- byte 0 is a version
- byte 1 is a flags field
- bytes 2 and 3 contain a big-endian payload length
Parse it like this:
function parseHeader(data) {
if (data.length < 4) {
throw new Error('Incomplete header')
}
return {
version: data.readUInt8(0),
flags: data.readUInt8(1),
payloadLength: data.readUInt16BE(2)
}
}
Check the available length before every structured read. Network input can stop halfway through a header, and a file can be truncated.
Then validate the decoded values before allocating memory for the payload. A claimed length of several gigabytes should not become a Buffer.alloc() call without an application limit.
Buffers in files and streams
Reading a file without an encoding returns a buffer:
import { readFile } from 'node:fs/promises'
const image = await readFile('photo.jpg')
console.log(Buffer.isBuffer(image)) // true
Pass an encoding when you want text:
const config = await readFile('config.json', 'utf8')
Network and file streams also emit buffers by default:
stream.on('data', chunk => {
console.log(Buffer.isBuffer(chunk))
})
Do not call toString() on arbitrary binary data. An image is not UTF-8 text, and decoding it loses information.
Common mistakes
Using string length as byte length
Use Buffer.byteLength() after choosing an encoding.
Using allocUnsafe() casually
Use Buffer.alloc() unless every byte is immediately overwritten.
Set limits before allocating
A requested buffer size can come from a file header, network message, or user input. Validate it before allocation.
const maximumPayload = 10 * 1024 * 1024
if (!Number.isSafeInteger(size) || size < 0 || size > maximumPayload) {
throw new Error('Invalid payload size')
}
const payload = Buffer.alloc(size)
Node has a maximum buffer length, but that runtime ceiling is far larger than most applications should accept. Your limit should reflect the real operation.
Remember concurrency too. A 20 MB buffer might be acceptable once and disastrous across 100 simultaneous requests. Streams help when you can process data incrementally.
Expecting slice() to copy
On Buffer, slice() and subarray() share memory. Use Buffer.from() for a copy.
Joining an unlimited stream in memory
Pipe or process chunks incrementally. A buffer still has to fit in memory.
Converting binary data to UTF-8
Keep bytes as bytes unless the format says they contain text.
Comparing buffers with ===
Use equals() for content.
How I use buffers
I usually let higher-level Node.js APIs manage buffers.
For text files, I request UTF-8 and work with strings. For images, archives, hashes, and protocol data, I keep buffers until I deliberately encode or decode something.
When I need part of a buffer briefly, I use subarray() and remember it shares memory. When the part must live independently, I copy it.
Most buffer bugs come from crossing one boundary without noticing: text to bytes, shared memory to copied memory, or a stream to one giant allocation.
Name that boundary in the code, and Buffer becomes a small, predictable tool.
Related posts about node: