Streams & Buffers

Reviewed & published by Brayan K

Streams let Node process data piece by piece as it arrives instead of loading it all into memory at once, and Buffers are the fixed-length chunks of raw binary data that streams move around.

Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

By the end of this lesson you'll know what a Buffer holds and how to convert it to and from text, you'll read a large file chunk by chunk with a readable stream, and you'll copy a file efficiently by piping one stream into another — all while keeping memory use flat.

What You'll Learn in This Lesson

1️⃣ Buffers: Chunks of Raw Bytes

Before streams, you need to understand what they carry. A Buffer is Node's container for raw binary data — a fixed-length sequence of bytes. Text, images, network packets: at the lowest level they're all just bytes, and a Buffer is how Node holds them.

Read the worked example below — every line is explained — then run it. Notice that printing the Buffer directly shows its bytes as hexadecimal numbers.

// A Buffer is a fixed-length chunk of raw binary data.
// Buffer.from(string) encodes the text into bytes (UTF-8 by default).
const buf = Buffer.from('hello');

// .toString() decodes those bytes back into text.
console.log(buf.toString());   // -> hello

// .length is the number of BYTES, not characters.
console.log(buf.length);       // -> 5

// Printing the buffer itself shows the raw bytes in hex.
console.log(buf);              // -> <Buffer 68 65 6c 6c 6f>

// You can read an individual byte by index (68 = 'h').
console.log(buf[0]);          // -> 104  (0x68 in decimal)

2️⃣ Readable Streams: Data Piece by Piece

A readable stream hands you a file (or a network response, or any source) in small chunks instead of all at once. You open one with fs.createReadStream(path) and then listen for two key events:

Why bother? Because fs.readFile loads the entire file into memory before you see a single byte. A 2 GB log file would need 2 GB of RAM. A stream processes that same file in ~64 KB chunks, so memory use stays low and constant no matter how big the file gets.

const fs = require('fs');

// The file this example reads. In your own project it would already exist;
// the block writes it so it can stand on its own. 143,966 characters is
// deliberate: the default chunk size is 65,536 bytes, so it arrives in
// exactly three pieces — two full ones and a remainder.
fs.writeFileSync('big.txt', 'x'.repeat(143966));

// createReadStream gives you the file PIECE BY PIECE instead of all at once.
// Passing 'utf8' makes each chunk a string instead of a raw Buffer.
const stream = fs.createReadStream('big.txt', 'utf8');

let totalChars = 0;

// 'data' fires once for every chunk Node reads off the disk.
stream.on('data', (chunk) => {
  totalChars += chunk.length;
  console.log('Got a chunk of', chunk.length, 'characters');
});

// 'end' fires once, after the LAST chunk has arrived.
stream.on('end', () => {
  console.log('Done. Total characters:', totalChars);
});

// Always handle 'error' — a missing file would otherwise crash the process.
stream.on('error', (err) => {
  console.error('Stream failed:', err.message);
});

Your turn. The program below works once you fill in the three blanks marked ___. Follow the 👉 hints, then run it and compare with the expected output.

// 🎯 YOUR TURN — replace each ___ then run it with: node turn.js

// 1) Make a Buffer from a string of your choice
const buf = Buffer.from(___);   // 👉 put text in 'single quotes', e.g. 'stream'

// 2) Log how many BYTES it holds
console.log(buf.___);           // 👉 the property that gives the byte count

// 3) Log it back as readable text
console.log(buf.___());         // 👉 the method that decodes bytes to a string

// ✅ Expected output (for 'stream'):
//    6
//    stream

3️⃣ Writable Streams & .pipe()

A writable stream is the opposite of a readable one: it's a destination you push chunks into. You create one with fs.createWriteStream(path). The magic happens when you connect a readable stream to a writable one with .pipe():

This is how you copy a multi-gigabyte file using only a tiny, constant amount of memory — the whole copy is just source.pipe(dest).

const fs = require('fs');

// Same stand-in file as the read example above, so this block runs alone too.
fs.writeFileSync('big.txt', 'x'.repeat(143966));

// A Readable stream: where the data comes FROM.
const source = fs.createReadStream('big.txt');

// A Writable stream: where the data goes TO.
const dest = fs.createWriteStream('copy.txt');

// pipe() connects them: every chunk read from source is written to dest,
// and Node automatically handles backpressure (pausing when dest is busy).
source.pipe(dest);

// 'finish' fires on the WRITABLE side when all data has been flushed.
dest.on('finish', () => {
  console.log('File copied with constant, low memory use!');
});

source.on('error', (err) => console.error('Read error:', err.message));
dest.on('error', (err) => console.error('Write error:', err.message));

Mini-Challenge: Count a File's Bytes

No blanks this time — just a brief and an outline to keep you on track. Stream a file, total up the byte length of each chunk, and print the result. Write it yourself, run it, and check your output against the example in the comments.

const fs = require('fs');

// 🎯 MINI-CHALLENGE: stream a file and count its bytes
// 1. Open 'notes.txt' with fs.createReadStream (no encoding = raw Buffers).
// 2. On each 'data' chunk, add chunk.length to a running total.
// 3. On 'end', print the total number of bytes.
// 4. On 'error', print the error message so a missing file won't crash you.
//
// ✅ Example output:
//    notes.txt is 2048 bytes

// your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Streams & Buffers

ConceptNode.js Syntax
Make a bufferBuffer.from(str)
Buffer to stringbuf.toString()
Open a read streamfs.createReadStream(p)
Open a write streamfs.createWriteStream(p)
Pipe one into anothersrc.pipe(dest)
Handle each chunkstream.on('data', fn)
Detect the endstream.on('end', fn)

🎉 Lesson Complete!

Practice quiz

What does Buffer.from('hello').length return?

  • The number 5 (bytes)
  • The string 'hello'
  • The number of words
  • A hex dump

Answer: The number 5 (bytes). length is the number of bytes; 'hello' is 5 ASCII bytes.

Which method decodes a Buffer's bytes back into readable text?

  • buf.decode()
  • buf.toString()
  • buf.read()
  • buf.parse()

Answer: buf.toString(). buf.toString() decodes bytes (UTF-8 by default) into a string.

Why prefer fs.createReadStream over fs.readFile for a huge file?

  • It is always faster on tiny files
  • It validates the file format
  • It keeps memory low by reading in chunks
  • It encrypts the data

Answer: It keeps memory low by reading in chunks. A stream processes ~64KB chunks, so memory stays flat instead of loading the whole file.

Which event fires once after the last chunk of a readable stream?

  • 'finish'
  • 'close'
  • 'data'
  • 'end'

Answer: 'end'. 'end' fires once on the readable side after the final chunk.

What does source.pipe(dest) automatically handle for you?

  • Backpressure
  • File encryption
  • JSON parsing
  • DNS lookups

Answer: Backpressure. pipe() pauses the source when the destination can't keep up — that's backpressure.

Why must you attach a 'error' listener to every stream?

  • To rename the file
  • Stream errors are emitted, and unhandled ones crash the process
  • To speed up reads
  • To enable utf8 mode

Answer: Stream errors are emitted, and unhandled ones crash the process. Stream errors aren't thrown; an unhandled 'error' event crashes Node.

How many bytes does Buffer.from('hello')[0] (the byte for 'h') equal?

  • 72
  • 5
  • 104
  • 0

Answer: 104. 'h' is 0x68, which is 104 in decimal.

What event fires on the WRITABLE side when all data is flushed?

  • 'end'
  • 'finish'
  • 'data'
  • 'drain'

Answer: 'finish'. The writable side emits 'finish' once every byte has been written.

Passing 'utf8' to createReadStream makes each chunk arrive as what?

  • A string
  • A raw Buffer
  • A number
  • An array

Answer: A string. With an encoding set, chunks are strings instead of Buffers.

Which is true about a Buffer's .length for the text '😀'?

  • It equals the character count
  • It is always 1
  • It counts bytes, so an emoji is more than 1
  • It throws an error

Answer: It counts bytes, so an emoji is more than 1. length counts bytes; a single emoji takes several UTF-8 bytes.

Continue this course

Frequently asked questions

What is the difference between a stream and a Buffer in Node.js?

A Buffer is a single, fixed-length chunk of raw binary data sitting in memory right now. A stream is the moving pipeline that delivers those chunks one after another over time. Think of a Buffer as one bucket of water and a stream as the hose: the hose hands you bucket after bucket so you never have to hold the whole reservoir at once. Streams emit Buffers (or strings, if you set an encoding) on their 'data' event.

When should I use a stream instead of fs.readFile?

Use a stream whenever a file might be large or its size is unknown. fs.readFile (and fs.readFileSync) load the entire file into memory before you get a single byte, so a 2 GB file needs 2 GB of RAM and can crash your process. A read stream processes the file in small chunks (64 KB by default), so memory use stays low and constant no matter how big the file is. For small config files, readFile is simpler and perfectly fine.

What does stream.pipe() actually do?

pipe() wires a readable stream's output straight into a writable stream's input. It forwards every chunk for you, ends the destination when the source finishes, and — crucially — handles backpressure automatically by pausing the readable side when the writable side can't keep up. It is the simplest and safest way to move data from one stream to another, which is why source.pipe(dest) is the idiomatic way to copy or transform files.

Why do I need to listen for the 'error' event on a stream?

Stream errors are not thrown like normal exceptions — they are emitted as an 'error' event. If you never attach a handler with stream.on('error', ...), an emitted error becomes an uncaught exception that crashes the whole Node process. Always add an error listener to every readable and writable stream so problems like a missing file or a full disk are handled gracefully instead of taking your app down.