Buffer
Why Buffer exists
Section titled “Why Buffer exists”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.
Creating a Buffer
Section titled “Creating a Buffer”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.
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 vs Buffer.allocUnsafe
Section titled “Buffer.alloc vs Buffer.allocUnsafe”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).
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): trueIllustrative 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 outputvar mystery = Buffer.allocUnsafe(8);console.log(mystery);// <Buffer 00 00 00 00 8f 12 40 55> <- or anything else. Never rely on this.Encodings: utf8, hex, base64
Section titled “Encodings: utf8, hex, base64”toString(encoding) converts a Buffer back to a string in the requested encoding.
Output (verified on Node 24)
utf8 : Node.js
hex : 4e6f64652e6a73
base64: Tm9kZS5qcw==
decoded: Node.jsbase64url — 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.
Output (verified on Node 24)
header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSJ9
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSJ9.SIGNATURE
standard base64: //++
base64url : __--
decoded payload: { sub: '1234567890', name: 'Ada' }Byte length vs string length
Section titled “Byte length vs string length”Output (verified on Node 24)
ascii chars: 5
ascii bytes: 5
thai chars: 6
thai bytes (utf8): 18
emoji .length: 7
emoji bytes (utf8): 9Slicing and copying
Section titled “Slicing and copying”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.
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.
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: trueMental Model
Section titled “Mental Model”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"]
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Buffer.alloc(n) | Always safe — deterministic zeroed memory | Pays 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 path | Contents are undefined until you overwrite them; forgetting to do so leaks old heap data |
.slice()/.subarray() view | Zero-copy — no new memory allocated | Mutating the view mutates the original buffer, which is easy to do by accident |
.toString() per chunk | Simplest possible code | Silently corrupts any multi-byte character split across a chunk boundary |
StringDecoder | Correct across chunk boundaries | One more object to carry through your stream-handling code |
Common Misconceptions
Section titled “Common Misconceptions”- “
Buffer.allocUnsafeis just a fasterBuffer.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, likeArray.prototype.slice()” — fact:Buffer#slice/#subarrayreturn a view over the same underlying memory; mutating the slice mutates the original.
Reading AI-generated code
Section titled “Reading AI-generated code”- 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 withBuffer.concat()or decoding withStringDecoder? This breaks the moment a multi-byte character (Thai, emoji, CJK) straddles a chunk boundary. - Does code compute a string’s size with
str.lengthinstead ofBuffer.byteLength(str, 'utf8')before allocating a fixed-size buffer or setting aContent-Lengthheader? Any non-ASCII input will overflow or truncate. - Is a JWT or URL token built with standard
base64instead ofbase64url, 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.