Skip to content

Buffer

JavaScript strings are stored internally as UTF-16 — two bytes per code point in the Basic Multilingual Plane. That is fine for text, but networking and filesystem work deals in raw bytes: a TCP packet, a JPEG image, or a binary protocol frame has no inherent encoding.

Buffer is Node’s answer: a fixed-length sequence of bytes exposed as a Uint8Array subclass. It predates typed arrays and is still the standard type you will see throughout Node’s streams, HTTP, crypto, and fs APIs.

The preferred way is Buffer.from(). The new Buffer() constructor is deprecated (DEP0005) — it still works and prints a DeprecationWarning, but never use it in new code.

Node.js
Output (verified on Node 24)
buf1: <Buffer 48 65 6c 6c 6f>
buf1 length: 5
buf2: <Buffer de ad be ef>
buf2 length: 4
buf3 (zeroed): <Buffer 00 00 00 00>

Buffer.alloc(size) zero-fills the memory before handing it to you — safe, but it costs a write over every byte. Buffer.allocUnsafe(size) skips that zero-fill and just gives you whatever bytes were already sitting in that region of the memory pool — faster, but those bytes could be anything, including leftover fragments of previously-freed buffers (old request bodies, old file contents, old secrets).

Node.js
Output (verified on Node 24)
alloc(8): <Buffer 00 00 00 00 00 00 00 00>
allocUnsafe(8) length: 8
allocUnsafe(8) is a Buffer: true
after fill(0): <Buffer 00 00 00 00 00 00 00 00>
now equal to alloc(8): true

Illustrative only — the exact bytes below will differ every time you run it, which is exactly the point:

// @skip-verify nondeterministic memory contents — illustrates allocUnsafe's danger, not something with a fixed expected output
var mystery = Buffer.allocUnsafe(8);
console.log(mystery);
// <Buffer 00 00 00 00 8f 12 40 55> <- or anything else. Never rely on this.

toString(encoding) converts a Buffer back to a string in the requested encoding.

Node.js
Output (verified on Node 24)
utf8  : Node.js
hex   : 4e6f64652e6a73
base64: Tm9kZS5qcw==
decoded: Node.js

base64url — the URL/cookie/header-safe encoding

Section titled “base64url — the URL/cookie/header-safe encoding”

Standard base64 can contain +, /, and trailing = padding — all three need escaping in a URL, a cookie, or an HTTP header. base64url (Node 15.7+) is the same encoding with a URL-safe alphabet (-/_ instead of +//) and no padding, which is exactly the format JWTs use for their header and payload segments.

Node.js
Output (verified on Node 24)
header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSJ9
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSJ9.SIGNATURE
standard base64: //++
base64url      : __--
decoded payload: { sub: '1234567890', name: 'Ada' }
Node.js
Output (verified on Node 24)
ascii chars: 5
ascii bytes: 5
thai chars: 6
thai bytes (utf8): 18
emoji .length: 7
emoji bytes (utf8): 9

buf.slice(start, end) (or buf.subarray) returns a view into the same memory — modifying the slice modifies the original. Use Buffer.from(slice) to get an independent copy.

Node.js
Output (verified on Node 24)
slice: Hello
buf after mutation: Jello, World!
copy: world
buf unchanged: Jello, World!

StringDecoder: decoding multi-byte characters split across chunks

Section titled “StringDecoder: decoding multi-byte characters split across chunks”

A network stream delivers bytes in whatever chunks the OS hands over — it has no idea where a UTF-8 character boundary falls. If a multi-byte character (like most Thai characters, which are 3 bytes each in UTF-8) is split across two chunks, calling .toString('utf8') on each chunk independently corrupts it: each half is an incomplete byte sequence, so both halves decode to the Unicode replacement character (�).

node:string_decoder’s StringDecoder fixes this: it buffers incomplete trailing bytes and prepends them to the next chunk, so multi-byte characters are only ever decoded once they’re complete.

Node.js
Output (verified on Node 24)
full string: สวัสดี
total bytes: 18
--- naive: toString() per chunk ---
chunk1.toString(): "ส�"
chunk2.toString(): "��ัสดี"
naive concat: ส���ัสดี
--- correct: StringDecoder buffers partial characters ---
decoded: สวัสดี
matches original: true
flowchart LR
  A["Buffer.alloc(n)"] -->|"zero-fills first"| Z["all bytes 0x00 — safe to read immediately"]
  B["Buffer.allocUnsafe(n)"] -->|"skips zero-fill"| G["whatever was in the memory pool — must overwrite before reading"]
  G -->|".fill() or write every byte"| Z
  S["multi-byte char split across two chunks"] -->|"toString() per chunk"| Corrupt["each half is incomplete -> replacement character"]
  S -->|"StringDecoder.write() per chunk"| Correct["partial bytes carried to next chunk -> correct string"]
alloc trades a zero-fill write for safety; allocUnsafe trades safety for speed; StringDecoder is the fix for chunk boundaries that split a character
ChoiceBenefitCost
Buffer.alloc(n)Always safe — deterministic zeroed memoryPays the cost of zero-filling even when you’re about to overwrite everything anyway
Buffer.allocUnsafe(n)Skips the zero-fill — faster in a hot pathContents are undefined until you overwrite them; forgetting to do so leaks old heap data
.slice()/.subarray() viewZero-copy — no new memory allocatedMutating the view mutates the original buffer, which is easy to do by accident
.toString() per chunkSimplest possible codeSilently corrupts any multi-byte character split across a chunk boundary
StringDecoderCorrect across chunk boundariesOne more object to carry through your stream-handling code
  • “Buffer.allocUnsafe is just a faster Buffer.alloc” — fact: it is faster because it skips zeroing memory; the bytes you get back are leftover data, not zeros, until you overwrite them yourself.
  • “base64 and base64url are the same thing with a different name” — fact: base64url substitutes -/_ for +// and drops padding, specifically so the result is safe to embed in a URL, cookie, or header without escaping.
  • “chunk.toString('utf8') is always safe for text streams” — fact: it is only safe when the chunk boundary never splits a multi-byte character — which a network stream cannot guarantee.
  • “buf.slice() returns a copy, like Array.prototype.slice()” — fact: Buffer#slice/#subarray return a view over the same underlying memory; mutating the slice mutates the original.
  • Is Buffer.allocUnsafe() used anywhere the buffer is read (logged, sent, hashed) before every byte has been explicitly written or .fill()ed?
  • Does a stream handler call chunk.toString('utf8') on each chunk independently, instead of accumulating with Buffer.concat() or decoding with StringDecoder? This breaks the moment a multi-byte character (Thai, emoji, CJK) straddles a chunk boundary.
  • Does code compute a string’s size with str.length instead of Buffer.byteLength(str, 'utf8') before allocating a fixed-size buffer or setting a Content-Length header? Any non-ASCII input will overflow or truncate.
  • Is a JWT or URL token built with standard base64 instead of base64url, requiring manual +///= escaping that’s easy to get wrong?

🐛 Spot the bug

const { Transform } = require('node:stream');
class Uppercase extends Transform {
_transform(chunk, encoding, callback) {
// chunk is a raw Buffer — this decodes each piece independently
var text = chunk.toString('utf8');
callback(null, text.toUpperCase());
}
}
Show the answer

_transform receives raw Buffer chunks with no guarantee that a chunk boundary respects UTF-8 character boundaries. Calling chunk.toString('utf8') on each chunk independently will corrupt any multi-byte character (Thai, emoji, CJK) that happens to be split across two chunks — the same failure mode shown in the StringDecoder demo above, where each half decodes to � instead of the real character. The fix is to hold a StringDecoder instance on the class (this._decoder = new StringDecoder('utf8')) and call this._decoder.write(chunk) inside _transform, plus this._decoder.end() in _flush to flush any trailing partial bytes.

Why is Buffer.byteLength(str) often different from str.length?
What does Buffer.from("deadbeef", "hex") produce?
What happens when you mutate a slice returned by buf.slice()?
What does this print? var safe = Buffer.alloc(4); var unsafe = Buffer.allocUnsafe(4); console.log(safe.equals(Buffer.from([0, 0, 0, 0])));
A UTF-8 byte stream delivers a 3-byte Thai character split as 2 bytes in one chunk and 1 byte in the next. What does chunk.toString('utf8') on each chunk independently produce?
Which encoding is the right choice for embedding a token directly in a URL query string with no manual escaping?