Readable Streams
A Readable stream is a source you pull data out of — a file being read, an incoming HTTP request, process.stdin, the output of a child process. The tricky part is that a readable has two modes of operation with very different behavior, and an internal buffer governed by highWaterMark. This page covers how to consume readables correctly and how their flow control actually works.
The modern way: async iteration
import { createReadStream } from 'node:fs'
const stream = createReadStream('big.log', { encoding: 'utf8' })
let lines = 0
for await (const chunk of stream) {
lines += chunk.split('\n').length - 1
}
console.log('approx lines:', lines)Two modes: paused and flowing
Every readable starts paused and switches to flowing depending on how you consume it. This single fact explains most stream confusion:
Mode | How you enter it | How data arrives |
|---|---|---|
Paused (default) | Initial state; call | You pull with |
Flowing | Attach a | Data is pushed at you via |
The classic event API
import { createReadStream } from 'node:fs'
const stream = createReadStream('file.txt')
const chunks = []
stream.on('data', (chunk) => chunks.push(chunk)) // flowing mode
stream.on('end', () => {
const all = Buffer.concat(chunks)
console.log('total bytes:', all.length)
})
stream.on('error', (err) => console.error(err))Event | Fires when |
|---|---|
| A chunk is available (flowing mode) |
| No more data — all chunks consumed |
| Something failed; stream may not emit |
| Stream and its resources are closed |
| Data is available to |
Paused mode: pulling with read()
In paused mode you pull explicitly. Listen for readable, then drain with read() until it returns null (buffer empty for now):
const stream = createReadStream('file.txt')
stream.on('readable', () => {
let chunk
while ((chunk = stream.read()) !== null) {
console.log(`read ${chunk.length} bytes`)
}
})
stream.on('end', () => console.log('done'))highWaterMark: the internal buffer
A readable buffers data internally up to its highWaterMark (default 64 KB for byte streams, 16 in object mode). When the buffer is full, the stream stops pulling from the underlying source until you consume some — this is backpressure working at the source.
import { createReadStream } from 'node:fs'
// Smaller buffer → more, smaller chunks; larger → fewer, bigger chunks
const stream = createReadStream('data.bin', { highWaterMark: 16 * 1024 }) // 16 KBObject mode
By default readables emit Buffer/string chunks. In object mode each chunk is an arbitrary JavaScript value — perfect for pipelines of parsed records (database rows, JSON objects) rather than bytes:
import { Readable } from 'node:stream'
// Emit objects, not bytes:
const users = Readable.from([
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Linus' },
])
for await (const user of users) {
console.log(user.name) // 'Ada', then 'Linus'
}Consuming the whole thing
import { createReadStream } from 'node:fs'
// Collect to one buffer/string (only for data that fits in memory!):
async function readAll(stream) {
const chunks = []
for await (const chunk of stream) chunks.push(chunk)
return Buffer.concat(chunks)
}
const buf = await readAll(createReadStream('config.json'))
console.log(buf.toString('utf8'))Quick reference
Goal | Approach |
|---|---|
Consume safely (default) |
|
Pipe to a writable |
|
Fine-grained pull | paused mode + |
From an array/iterable |
|
Tune buffering |
|