Streams are one of Node.js's most powerful abstractions for handling data. Rather than loading entire files or network payloads into memory, streams process data in chunks as it flows through the application. There are four kinds: Readable streams produce data (such as fs.createReadStream), Writable streams consume data (such as fs.createWriteStream), Duplex streams are both readable and writable (such as a TCP socket), and Transform streams modify data as it passes through (such as zlib.createGzip).
To connect a readable stream to a writable stream, you use the .pipe() method, which automatically manages the flow of data and handles backpressure along the way. The modern alternative is the pipeline() function from stream/promises, which has the advantage of propagating errors automatically and cleaning up resources if any step fails. Without proper piping, manually coordinating streams is error-prone and easy to get wrong.
Backpressure occurs when a writable stream cannot consume data as fast as a readable stream produces it. Node.js signals this condition by having writable.write() return false, indicating that the readable stream should pause. When the writable is ready for more data, it emits a 'drain' event, allowing the readable to resume. Properly handling backpressure is critical for memory efficiency, especially with large files or high-throughput network operations.
Buffers complement streams by providing a way to work with raw binary data. A Buffer is a fixed-size chunk of memory allocated outside the V8 heap, used for handling binary data directly in operations like file I/O, network protocols, and image processing. Buffers can be created with Buffer.alloc(size) for zero-filled memory, Buffer.from(string) for strings, or Buffer.from(array) for raw byte arrays. Common manipulations include buf.toString('utf8') for decoding, buf.slice(start, end) for sub-buffers, and Buffer.concat([buf1, buf2]) for merging. Together, streams and buffers form the foundation for efficient I/O in Node.js.