Introduction to Streams
A stream is an abstraction for processing data piece by piece as it arrives, instead of loading the whole thing into memory at once. Read a 50 GB log file, proxy an HTTP response, compress a video on the fly — all of these work on a 50 GB file but only ever hold a few kilobytes in memory at a time. Streams are one of Node's most powerful (and most misunderstood) ideas, and they underpin fs, http, crypto, zlib, and more.
The problem streams solve
Compare reading a large file two ways. The buffered approach loads everything before you can touch a byte:
import { readFile } from 'node:fs/promises'
// ✗ Loads the ENTIRE file into RAM — a 2 GB file needs 2 GB of memory
const data = await readFile('huge.log', 'utf8')
process(data)The streaming approach processes the file in small chunks, with near-constant memory regardless of file size:
import { createReadStream } from 'node:fs'
// ✓ Reads ~64 KB at a time — memory stays flat for any file size
const stream = createReadStream('huge.log', 'utf8')
for await (const chunk of stream) {
process(chunk) // handle each piece as it arrives
}The four stream types
Type | Direction | Examples |
|---|---|---|
| Source you read from |
|
| Sink you write to |
|
| Both, independently | TCP socket ( |
| Duplex that modifies data |
|
Each gets its own page: Readable Streams, Writable Streams, and Duplex & Transform Streams.
Chunks: the unit of flow
Data moves through a stream in chunks — usually Buffer objects (binary), or strings if an encoding is set, or arbitrary JavaScript values in object mode. You rarely choose the chunk size; the stream and OS decide based on the highWaterMark (default 64 KB for file/byte streams, 16 objects in object mode).
Piping: connecting streams
The real power is composition. pipe (or better, pipeline) connects a readable's output to a writable's input, automatically handling chunk flow and backpressure:
import { createReadStream, createWriteStream } from 'node:fs'
import { createGzip } from 'node:zlib'
import { pipeline } from 'node:stream/promises'
// Read → compress → write, chunk by chunk, with backpressure handled:
await pipeline(
createReadStream('input.log'),
createGzip(),
createWriteStream('input.log.gz'),
)
console.log('compressed')Backpressure: the core concept
Streams are EventEmitters
Under the classic API, streams communicate via events — data, end, error, finish, drain. This is why understanding EventEmitter pays off here:
import { createReadStream } from 'node:fs'
const stream = createReadStream('file.txt')
stream.on('data', (chunk) => console.log('got', chunk.length, 'bytes'))
stream.on('end', () => console.log('done'))
stream.on('error', (err) => console.error('failed', err))When to use streams
Large or unbounded data — files, uploads, downloads bigger than you want in RAM.
Pipelines — read → transform → write, especially compress/encrypt/parse.
Low latency — start responding before all input has arrived (HTTP, proxies).
Not for small, in-memory data — if it comfortably fits in memory and you need it all at once,
readFile/writeFileare simpler and fine.