Skip to content
Node.js nd streams 5 min read

Readable Streams

A Readable stream is the source end of Node’s streaming model: it represents data that arrives over time — a file being read from disk, an HTTP response body, the output of a child process, or a custom generator. Instead of loading everything into memory at once, a readable stream hands you the data in chunks, letting you process gigabytes with a small, fixed memory footprint. The catch is that a readable stream can deliver those chunks in two different modes, and choosing the right one — and the right API — is the key to consuming streams correctly.

The core events

A readable stream communicates almost entirely through events. The three you will reach for constantly are data, end, and error.

  • data fires once per chunk, with the chunk as its argument. Attaching a data listener switches the stream into flowing mode (more on that below).
  • end fires exactly once, after the last chunk has been consumed and there is no more data. It carries no argument.
  • error fires if the underlying source fails. Like every emitter, an unhandled error throws and crashes the process, so always attach a handler.
import { createReadStream } from 'node:fs';

const stream = createReadStream('access.log', { encoding: 'utf8' });

let bytes = 0;
stream.on('data', (chunk) => {
  bytes += chunk.length;
});
stream.on('end', () => {
  console.log(`Finished. Read ${bytes} characters.`);
});
stream.on('error', (err) => {
  console.error('Read failed:', err.message);
});

Output:

Finished. Read 48213 characters.

By default a readable stream emits Buffer objects. Passing an encoding (as above) makes it emit decoded strings instead; you can also set it later with stream.setEncoding('utf8').

Flowing mode vs paused mode

Every readable stream is in one of two states. In paused mode (the default for a freshly created stream) data sits in an internal buffer until you explicitly pull it out by calling read(). In flowing mode data is pushed to you automatically as fast as it arrives, delivered through data events. The distinction matters because it determines who controls the pace of consumption.

AspectFlowing modePaused mode
How you get datadata eventsread() calls
Who drives the paceThe stream (pushes)Your code (pulls)
Entered byAdding a data listener, resume(), or pipe()Default state; pause(); calling read() with no data listener
Best forSimple “consume everything” casesFine-grained, on-demand reads

A stream switches to flowing mode the moment you attach a data listener, call resume(), or pipe() it to a writable. It switches back to paused when you call pause() or unpipe() all destinations.

A subtle gotcha: if you switch a stream to flowing mode (for example by calling resume()) but never attach a data listener or a pipe(), the data is read from the source and silently discarded. Flowing mode without a consumer drops chunks.

Reading in paused mode with read()

In paused mode you pull data yourself. The readable event tells you that there is data available to be read, and read([size]) returns a chunk (or null when the buffer is empty). This gives you precise control — useful for protocols where you need an exact number of bytes.

import { createReadStream } from 'node:fs';

const stream = createReadStream('data.bin');

stream.on('readable', () => {
  let chunk;
  // read() returns null when no more data is currently buffered.
  while ((chunk = stream.read()) !== null) {
    console.log(`Got ${chunk.length} bytes`);
  }
});
stream.on('end', () => console.log('No more data.'));

Output:

Got 65536 bytes
Got 65536 bytes
Got 18272 bytes
No more data.

Calling read(size) with a number requests exactly that many bytes, returning null until enough have buffered. Without an argument, read() returns whatever is currently available.

Pausing and resuming a flowing stream

Even in flowing mode you can temporarily throttle a stream with pause() and resume(). This is the manual building block behind backpressure: stop pulling from the source while a slow consumer catches up, then resume.

import { createReadStream } from 'node:fs';

const stream = createReadStream('big.csv', { encoding: 'utf8' });

stream.on('data', (chunk) => {
  console.log(`Processing ${chunk.length} chars`);
  stream.pause(); // stop the firehose
  console.log('Paused for 100ms...');
  setTimeout(() => {
    console.log('Resuming.');
    stream.resume();
  }, 100);
});
stream.on('end', () => console.log('Done.'));

Output:

Processing 65536 chars
Paused for 100ms...
Resuming.
Processing 65536 chars
Paused for 100ms...
Resuming.
Done.

Consuming with for-await-of

Modern Node makes all of this far simpler. A Readable is an async iterable, so you can consume it with a for await...of loop. Each iteration yields the next chunk, the loop ends naturally at end, and a thrown error propagates to a normal try/catch — no manual event wiring. This is the recommended pattern for most application code.

import { createReadStream } from 'node:fs';

async function countLines(path) {
  const stream = createReadStream(path, { encoding: 'utf8' });
  let lines = 0;
  try {
    for await (const chunk of stream) {
      lines += chunk.split('\n').length - 1;
    }
  } catch (err) {
    console.error('Failed:', err.message);
    return;
  }
  console.log(`${path} has ${lines} lines`);
}

await countLines('access.log');

Output:

access.log has 1204 lines

The async iterator drives the stream in paused mode under the hood, applying backpressure automatically: it only pulls the next chunk when the loop body is ready. If you break out of the loop early, the stream is destroyed for you, releasing the underlying file descriptor or socket.

Best Practices

  • Always attach an error listener (or wrap a for await...of loop in try/catch); an unhandled error event crashes the process.
  • Prefer for await...of for consuming readable streams — it handles end, errors, and backpressure with ordinary control flow.
  • Set an encoding or call setEncoding() when you want strings, rather than concatenating Buffer chunks by hand.
  • Pick one consumption style per stream: mixing data listeners and manual read() calls leads to confusing, mode-switching behavior.
  • Never switch a stream to flowing mode (via resume()) without a consumer attached, or you will silently discard data.
  • Use pause()/resume() only for manual throttling; for stream-to-stream transfers, let pipe() or pipeline() manage backpressure instead.
Last updated June 14, 2026
Was this helpful?