Understand bytes and encodings in Node.js. Create Buffers, convert to hex and base64, slice safely, read and write numbers, and handle UTF-8 correctly.

Bishal Biswas
Self Learner
Computers only store numbers
Everything on your computer, text, photos, music, is stored as bytes. A byte is a small number from 0 to 255.
Think of a row of post office boxes, each holding one number. A photo is a very long row of boxes. A text file is a row of boxes too, where each number stands for a letter. The rule that says which number means which letter is called an encoding.
A Buffer is Node’s way of holding a row of raw bytes in memory. You use Buffers when you work with files, network data, images, hashes and anything else that is not plain text.
Why not just use strings?
JavaScript strings are made for text. They are great for 'Hello, Asha', but they cannot safely hold an image or a zip file. Some byte patterns are not valid text at all.
A Buffer holds any bytes, exactly as they are. Node uses Buffers under the hood whenever it reads a file or receives data from the network without an encoding.
Creating a Buffer
Buffer is available everywhere, but importing it from node:buffer makes your code clearer:
// create.mjs
import { Buffer } from 'node:buffer';
const buf = Buffer.from('Hi Asha');
console.log(buf);
<Buffer 48 69 20 41 73 68 61>
Each pair is one byte written in hexadecimal (base 16). 48 is the byte for H, 69 is i, 20 is a space, and so on.
There are three common ways to make a Buffer:
Buffer.from('Hi'); // from a string (UTF-8 by default)
Buffer.from([72, 105]); // from an array of byte values
Buffer.alloc(4); // 4 bytes, all set to zero
<Buffer 48 69>
<Buffer 48 69>
<Buffer 00 00 00 00>
Note: You may also see
Buffer.allocUnsafe(size). It is faster because it skips filling the memory with zeros, so it can contain old leftover data. Only use it if you will overwrite every byte straight away. When in doubt, useBuffer.alloc.
Reading bytes
A Buffer behaves a lot like an array of numbers:
const buf = Buffer.from('Hi Asha');
console.log(buf.length); // prints: 7 (number of bytes)
console.log(buf[0]); // prints: 72 (the byte for 'H')
for (const byte of Buffer.from('Hi')) {
console.log(byte); // prints: 72, then 105
}
In fact, a Buffer is a Uint8Array (a standard JavaScript typed array of bytes) with extra Node methods added:
console.log(buf instanceof Uint8Array); // prints: true
console.log(Buffer.isBuffer(buf)); // prints: true
Each slot can only hold 0 to 255. Larger or negative values wrap around:
const b = Buffer.alloc(2);
b[0] = 300; // 300 does not fit, so it becomes 300 - 256 = 44 (hex 2c)
b[1] = -1; // becomes 255 (hex ff)
console.log(b); // prints: <Buffer 2c ff>
Encodings: turning bytes into text
buf.toString(encoding) turns bytes into a string. Buffer.from(string, encoding) goes the other way.
// encodings.mjs
import { Buffer } from 'node:buffer';
const buf = Buffer.from('Hi Asha');
console.log(buf.toString()); // Hi Asha
console.log(buf.toString('hex')); // 48692041736861
console.log(buf.toString('base64')); // SGkgQXNoYQ==
console.log(Buffer.from('SGkgQXNoYQ==', 'base64').toString()); // Hi Asha
console.log(Buffer.from('48692041736861', 'hex').toString()); // Hi Asha
The encodings you will use most:
| Encoding | What it is | Used for |
|---|---|---|
'utf8' |
The standard for text. The default. | Almost all text |
'hex' |
Each byte as two characters 0-9a-f |
Hashes, debugging |
'base64' |
Bytes as letters, digits, + and / |
Sending binary data inside text, like JSON or email |
'base64url' |
Like base64 but with - and _ and no = padding |
URLs, tokens |
'latin1' |
One byte per character, old Western text | Old file formats |
console.log(Buffer.from('hi?>').toString('base64')); // prints: aGk/Pg==
console.log(Buffer.from('hi?>').toString('base64url')); // prints: aGk_Pg
Characters are not bytes
In UTF-8, plain English letters take 1 byte each. Accented letters take 2. Many symbols and emoji take 3 or 4.
const word = 'café 😊';
console.log(word.length); // prints: 7 (JavaScript string length)
console.log(Buffer.byteLength(word)); // prints: 10 (bytes in UTF-8)
console.log(Buffer.from(word));
<Buffer 63 61 66 c3 a9 20 f0 9f 98 8a>
é became two bytes (c3 a9) and the emoji became four (f0 9f 98 8a).
Tip: When a web server sends a
Content-Lengthheader, it must be the number of bytes, not characters. UseBuffer.byteLength(text), nottext.length.
Slicing with subarray
buf.subarray(start, end) gives you part of a Buffer. It does not copy. The new Buffer looks at the same memory, so changing one changes the other:
// subarray.mjs
import { Buffer } from 'node:buffer';
const buf = Buffer.from('Hi Asha');
const name = buf.subarray(3, 7);
console.log(name.toString()); // prints: Asha
name[0] = 0x61; // the byte for lowercase 'a'
console.log(buf.toString()); // prints: Hi asha
This is fast, but surprising. If you need an independent copy, wrap it in Buffer.from():
const copy = Buffer.from(buf.subarray(3, 7)); // a real copy
Note: Older code uses
buf.slice(). It does the same thing assubarray()and is deprecated in the docs, becauseslice()on a normalUint8Arraymakes a copy. Usesubarray()to avoid confusion.
Joining, comparing and searching
// helpers.mjs
import { Buffer } from 'node:buffer';
const full = Buffer.concat([Buffer.from('Hello, '), Buffer.from('Ravi')]);
console.log(full.toString()); // prints: Hello, Ravi
console.log(Buffer.from('abc').equals(Buffer.from('abc'))); // prints: true
console.log(Buffer.from('Hi Asha').indexOf('Asha')); // prints: 3
console.log(Buffer.from('Hi Asha').includes('Ravi')); // prints: false
Buffer.concat is what you use to collect chunks of data into one Buffer, which you will do often with streams.
Reading and writing numbers
Binary file formats and network protocols store numbers in a fixed number of bytes. Buffers have methods to read and write them:
// numbers.mjs
import { Buffer } from 'node:buffer';
const header = Buffer.alloc(8);
header.writeUInt16BE(500, 0); // 2 bytes at position 0
header.writeUInt32LE(123456, 2); // 4 bytes at position 2
header.writeInt8(-5, 6); // 1 signed byte at position 6
header.writeUInt8(255, 7); // 1 byte at position 7
console.log(header);
console.log(header.readUInt16BE(0), header.readUInt32LE(2), header.readInt8(6), header.readUInt8(7));
<Buffer 01 f4 40 e2 01 00 fb ff>
500 123456 -5 255
The names tell you everything:
UIntmeans unsigned (0 and up).Intmeans it can be negative.8,16,32is the size in bits (1, 2 or 4 bytes).BE(big-endian) stores the biggest part first.LE(little-endian) stores the smallest part first. The file format you are reading tells you which one to use.
Buffers and files
When you read a file without an encoding, you get a Buffer:
import { readFile } from 'node:fs/promises';
const bytes = await readFile('photo.png'); // a Buffer
const text = await readFile('notes.txt', 'utf8'); // a string
Use a Buffer for images, PDFs, zip files and anything else that is not text. You can check the first few bytes to see what kind of file it is. PNG files always start with the bytes 89 50 4e 47.
TextEncoder and TextDecoder
Browsers do not have Buffer. They use TextEncoder and TextDecoder with plain Uint8Arrays. Node has these as globals too, which is handy for code that must run in both places:
const bytes = new TextEncoder().encode('Hi');
console.log(bytes); // prints: Uint8Array(2) [ 72, 105 ]
console.log(new TextDecoder().decode(bytes)); // prints: Hi
Common mistakes
Cutting a character in half
When data arrives in pieces, a multi-byte character can be split between two chunks. Converting each chunk on its own gives broken characters:
// Wrong
const bytes = Buffer.from('😊'); // 4 bytes
console.log(bytes.subarray(0, 2).toString(), bytes.subarray(2).toString());
// prints: � ��
Collect the chunks and join them first with Buffer.concat, or use a StringDecoder, which holds back incomplete characters until the rest arrives:
// Right
import { StringDecoder } from 'node:string_decoder';
const decoder = new StringDecoder('utf8');
console.log(decoder.write(bytes.subarray(0, 2))); // prints an empty line: waiting for more
console.log(decoder.write(bytes.subarray(2))); // prints: 😊
Streams do this for you when you set an encoding, as you will see in the Streams lesson.
Using length for byte counts
// Wrong: 7, but the text is 10 bytes
const size = 'café 😊'.length;
// Right: 10
const size = Buffer.byteLength('café 😊');
Expecting subarray to copy
Remember that subarray shares memory. If you keep a small piece of a huge Buffer, the whole huge Buffer stays in memory too. Copy with Buffer.from(piece) when you keep it for a long time.
Printing a Buffer as JSON
console.log(JSON.stringify(Buffer.from('Hi')));
// prints: {"type":"Buffer","data":[72,105]}
That is rarely what you want. Convert to a string first, for example buf.toString('base64').
Quick recap
- A byte is a number from 0 to 255. A Buffer is a fixed-size row of bytes.
- Create with
Buffer.from(...)orBuffer.alloc(size). AvoidallocUnsafeunless you fill it at once. toString(encoding)andBuffer.from(string, encoding)convert between bytes and text:utf8,hex,base64,base64url.- Characters can take 1 to 4 bytes. Use
Buffer.byteLengthfor sizes. subarrayshares memory. Copy withBuffer.from(...)when needed.Buffer.concatjoins chunks.readUInt16BEand friends read numbers from binary data.- Reading a file without an encoding gives you a Buffer.
// buffers.mjs: bytes, encodings and binary data
import { Buffer } from 'node:buffer';
import { writeFile, readFile } from 'node:fs/promises';
// Text to bytes and back again
const greeting = Buffer.from('Hi Asha');
console.log(greeting); // <Buffer 48 69 20 41 73 68 61>
console.log(greeting.toString('hex')); // 48692041736861
console.log(greeting.toString('base64')); // SGkgQXNoYQ==
console.log(Buffer.from('SGkgQXNoYQ==', 'base64').toString()); // Hi Asha
// Characters are not bytes: some take 2, 3 or 4 bytes in UTF-8
const word = 'café 😊';
console.log(word.length, 'characters,', Buffer.byteLength(word), 'bytes');
// subarray shares memory: changing it changes the original
const name = greeting.subarray(3);
name[0] = 0x61; // the byte for 'a'
console.log(greeting.toString()); // Hi asha
// Joining buffers
const full = Buffer.concat([Buffer.from('Hello, '), Buffer.from('Ravi')]);
console.log(full.toString());
// ---
// Writing and reading numbers in a binary "header"
const header = Buffer.alloc(6); // 6 zero-filled bytes
header.writeUInt16BE(2026, 0); // a year in 2 bytes, big-endian
header.writeUInt32LE(123456, 2); // an order id in 4 bytes, little-endian
console.log(header, header.readUInt16BE(0), header.readUInt32LE(2));
// Spot a file type from its first bytes (its "magic number")
const signatures = {
png: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
pdf: Buffer.from('%PDF'),
zip: Buffer.from([0x50, 0x4b, 0x03, 0x04]),
};
function detectType(bytes) {
for (const [type, magic] of Object.entries(signatures)) {
if (bytes.subarray(0, magic.length).equals(magic)) return type;
}
return 'unknown';
}
// Save some bytes to a file, then read them back without an encoding
await writeFile('fake.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const fileBytes = await readFile('fake.png'); // no 'utf8', so we get a Buffer
console.log('fake.png looks like:', detectType(fileBytes));
console.log('notes text looks like:', detectType(Buffer.from('Buy milk')));
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready