Articles

Node.js Streams Explained: Readable, Writable, and Piping

Node.js streams process data in chunks instead of loading it all into memory. How readable, writable, and transform streams work, and when to reach for them.

Takina Takina · · 4 min read
Code editor with dark theme showing source code

A stream in Node.js is an interface for reading or writing data incrementally, in chunks, instead of loading an entire file or payload into memory at once. Streams are how Node handles a two-gigabyte file upload without allocating two gigabytes of RAM, and they’re built into the runtime — fs, http, and process.stdout are all backed by streams under the hood.

Why chunked processing matters

Consider reading a large file. The naive approach — fs.readFileSync() — loads the whole file into a single buffer before your code can touch a byte of it. For a small config file, fine. For a multi-gigabyte log file or video upload, that’s a memory spike waiting to happen, and it means the consumer can’t start processing until the entire read finishes.

Streams process data as it arrives. A fs.createReadStream() reads a file in configurable chunks (64KB by default), emitting each chunk as soon as it’s available. Downstream code can start transforming or forwarding data before the source has finished producing it. This is the same idea behind server-sent events on the network side — data flows continuously rather than arriving as one blocking payload.

The four stream types

Node’s stream module defines four base types, all implementing EventEmitter:

  • Readable — a source of data you consume. fs.createReadStream(), an incoming HTTP request body, process.stdin.
  • Writable — a destination you write data to. fs.createWriteStream(), an outgoing HTTP response, process.stdout.
  • Duplex — both readable and writable, with independent internal buffers for each direction. A TCP socket is the canonical example.
  • Transform — a duplex stream where the output is derived from the input. zlib.createGzip() reads uncompressed bytes in and writes compressed bytes out.

Most application code only needs to consume readables and writables, or write a custom transform for a specific data massaging step.

Piping: connecting streams together

The .pipe() method connects a readable stream’s output directly to a writable stream’s input, handling the read/write cycle and — critically — backpressure automatically:

const fs = require("node:fs");
const zlib = require("node:zlib");

fs.createReadStream("input.log")
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream("input.log.gz"));

This reads input.log in chunks, compresses each chunk as it arrives, and writes the compressed output — all without ever holding the full file in memory. Each .pipe() call returns the destination stream, so chains like this read left to right as a pipeline.

The modern alternative, stream.pipeline() (or its promise-based form), does the same job but also propagates errors correctly and cleans up all streams if any one of them fails or the process is aborted — .pipe() alone leaves you responsible for wiring up error handlers on every stream in the chain yourself.

const { pipeline } = require("node:stream/promises");

await pipeline(
  fs.createReadStream("input.log"),
  zlib.createGzip(),
  fs.createWriteStream("input.log.gz")
);

Backpressure

Backpressure is what keeps a fast producer from overwhelming a slow consumer. If a readable stream produces data faster than a writable stream can accept it, the writable’s internal buffer fills up. .write() returns false when the buffer is full, signaling the producer to pause; the writable emits a drain event when it’s ready for more.

.pipe() and pipeline() implement this automatically — pause the source when the destination’s buffer is full, resume when it drains. It’s the main reason to prefer piping over manually reading and writing chunks yourself: getting backpressure right by hand is easy to get subtly wrong, usually by buffering unboundedly under load.

Streams and HTTP

An incoming request in an HTTP server handler is a readable stream, and the response object is writable. This is why you can pipe a file directly to a response without buffering it server-side:

const http = require("node:http");
const fs = require("node:fs");

http.createServer((req, res) => {
  fs.createReadStream("large-video.mp4").pipe(res);
}).listen(3000);

The server starts sending bytes to the client as soon as the file starts reading, rather than waiting to load the entire file first — lower time-to-first-byte and flat memory usage regardless of file size. This pattern underlies most static file serving and is worth knowing even if you’re mostly using a framework, since misusing it (buffering a large response into a string before sending) is a common source of memory issues in Node services. If you’re building an API around this, see what a REST API is for the surrounding request/response model, or what a WebSocket is for the case where you need bidirectional, persistent data flow instead of one-shot streaming.

Web Streams vs Node streams

Modern Node also implements the Web Streams API (ReadableStream, WritableStream, TransformStream) — the same interface browsers use for fetch() response bodies. Node’s native streams predate this standard and remain the primary interface for fs and http, but the two are interoperable: Readable.toWeb() and Readable.fromWeb() convert between them. If you’re writing code that needs to run in both Node and browser/edge environments (like a Cloudflare Worker), the Web Streams API is the more portable choice.

When to reach for streams

Streams pay off when data is large relative to available memory, arrives incrementally (network requests, file uploads), or needs to flow through multiple processing stages (read → decompress → parse → write). For small, fixed-size data — a JSON config, a short API payload — the simplicity of readFile/loading the full body into memory usually wins; the stream API’s event-driven model adds real complexity that isn’t worth it below a certain size.

The takeaway

Node streams process data in chunks instead of all at once, keeping memory flat regardless of payload size and letting downstream consumers start working before the source finishes producing. .pipe() or stream.pipeline() wires readables to writables while handling backpressure automatically — prefer pipeline() for its built-in error propagation and cleanup. Reach for streams when data is large or arrives incrementally; for small fixed payloads, the simpler buffered APIs are usually the better default.

Takina Takina · · 5 min read

ESM vs CommonJS: JavaScript Modules Explained

ESM and CommonJS are JavaScript's two module systems — how import/export differs from require/module.exports, and when each one is used.

#JavaScript #Node.js #Web Development
Takina Takina · · 4 min read

TypeScript Abstract Classes, Explained

Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.

#TypeScript #JavaScript #Web Development