Imagine trying to drink from a firehose: overwhelming and chaotic. Now, think of sipping water from a glass: controlled and efficient. That’s exactly how Node.js readable streams handle data: processing it in small chunks instead of overwhelming our application.
Node.js’s streaming architecture is key to its high performance. In this guide, we’ll dive into Node.js readable streams  —  the pipelines that bring data into our application. We’ll explore how to work with them, build our application with composable stream components, and handle errors gracefully.
Let’s get started!
There are four primary types of Node.js streams, each serving a specific purpose:
Stream type | Role | Common use cases |
---|---|---|
Readable streams | Fetch data from a source | Files, HTTP requests, user input |
Writable streams | Send data to a destination | Files, HTTP responses |
Duplex streams | Bidirectional data flow | TCP sockets, WebSocket connections |
Transform streams | A subtype of duplex streams that modifies data as it flows through | Compression, encryption, parsing |
In this article, we’ll focus on readable streams.
Node.js readable streams act as data sources, allowing us to consume information from files, network requests, and user input. By processing data in small, manageable chunks, they prevent memory overload and enable scalable, real-time data handling.
We can create readable streams using the stream.Readable
class or its specialized implementations.
Common readable stream implementations include:
fs.createReadStream
: For streaming data from files on disk, it is particularly useful for handling large datasets.http.IncomingMessage
: Handle incoming HTTP request bodies, commonly used in Express/Node.js serversprocess.stdin
: Capture real-time user input from the command lineHere is an example of reading the contents of the input.txt
file using a readable stream:
const fs = require("fs"); // Create a readable stream from a file const readStream = fs.createReadStream("input.txt", { encoding: "utf-8" });
While Node.js provides built-in readable streams, there are times when we need to generate or adapt data in a custom way. Custom readable streams are suitable for:
Below is an example of a custom readable stream. We extend the Readable
class and implement the _read()
method:
const { Readable } = require('stream'); // 1. Extend the Readable class class HelloWorldStream extends Readable { // 2. Implement the _read() method _read(size) { // Push data incrementally this.push('Hello, '); // First chunk this.push('world!'); // Second chunk // Signal end of data by pushing `null` this.push(null); } } // 3. Instantiate and consume the stream const helloWorld = new HelloWorldStream(); helloWorld.on('data', (chunk) => { console.log('Received chunk:', chunk.toString()); }); // Output: // Received chunk: Hello, // Received chunk: world!
As the code above shows, we can control how data is generated and chunked using the custom readable stream.
Readable streams in Node.js are event-driven, allowing us to handle key stages of the data lifecycle. By listening to specific events, we can process data chunks, react to errors, and detect when the stream has completed or closed.
Key events in a readable stream include:
data
: Emitted when a chunk of data is available to be readreadable
: Emitted when data is available to be readend
: Emitted when there is no more data to be readerror
: Emitted if an error occurs (e.g., file not found or permission issues)close
: Emitted when the stream and any underlying resources are closedIn the following example, we set up event listeners to read a file, log the chunks, handle potential errors, and log a message upon completion and stream closure:
const fs = require('fs'); const inputFilePath = 'example.txt'; // Create a readable stream const readStream = fs.createReadStream(inputFilePath, { encoding: 'utf-8' }); // Listen for 'data' events to process chunks readStream.on('data', (chunk) => { console.log('Received chunk:', chunk); }); // Listen for 'end' to detect when reading is complete readStream.on('end', () => { console.log('Finished reading the file.'); }); // Listen for 'error' to handle failures readStream.on('error', (err) => { console.error('An error occurred:', err.message); }); // Listen for 'close' to perform cleanup readStream.on('close', () => { console.log('Stream has been closed.'); });
Readable streams operate in two modes: flowing and paused, offering a balance between control and performance, giving developers fine-grained control over data consumption:
Mode | Behavior | Use case |
---|---|---|
Flowing | Data is read as fast as possible, emitting data events | Continuous processing |
Paused | Data must be explicitly read using .read() | Precise control over data flow |
Here’s an example of transitions between these two modes:
const fs = require("fs"); // Create a readable stream from a file const readStream = fs.createReadStream("input.txt", { encoding: "utf-8" }); // Starts in paused mode console.log(readStream.isPaused()); // true // Switch to flowing mode readStream.on('data', (chunk) => { console.log('Auto-received:', chunk); }); console.log(readStream.isPaused()); // false // Return to paused mode readStream.pause(); // Manually pull data in paused mode readStream.on('readable', () => { let chunk; while ((chunk = readStream.read()) !== null) { console.log('Manually read:', chunk); } }); // Switch back to flowing mode readStream.resume();
This dynamic switching between paused and flowing modes provides flexibility. Use paused mode when we need precise control over data consumption (e.g., batch operations), and flowing mode for continuous processing (e.g., live data feeds).
Robust error handling is essential when working with Node.js streams because they can fail due to missing files, permission errors, network interruptions, or corrupted data.
Since streams inherit from EventEmitter
, they emit an 'error'
event when something goes wrong. Proper error handling involves listening to the 'error'
event and implementing appropriate recovery strategies.
General steps for error handling in streams:
'error'
events: Attach an event listener to the readable stream to catch errorsconst fs = require('fs'); const readableStream = fs.createReadStream('example.txt', 'utf-8'); // Listen for 'error' events readableStream.on('error', (err) => { console.error('Stream error:', err.message); // Clean up resources readableStream.destroy(); // Close the stream and release resources }); // Optionally, pass an error to destroy() to emit an 'error' event // readableStream.destroy(new Error('Custom error message'));
destroy()
for cleanupThe destroy()
method is the recommended approach to close a stream and release its resources. It ensures the stream is immediately closed and underlying resources are being released.
You can optionally pass an error to destroy()
:
readableStream.destroy(new Error('Stream terminated due to an issue.'));
This will subsequently emit an 'error'
event on the stream, which can be useful for resource cleanup and signaling an unexpected termination.
In real-world applications, transient issues like network glitches or temporary file locks can cause stream errors. Instead of failing immediately, implementing a retry mechanism can help recover gracefully. Below is an example of how to add retry logic to a readable stream:
const fs = require('fs'); function createReadStreamWithRetry(filePath, retries = 3) { let attempts = 0; function attemptRead() { const readableStream = fs.createReadStream(filePath, 'utf8'); // Handle data chunks readableStream.on('data', (chunk) => { console.log(`Received chunk: ${chunk}`); }); // Handle successful completion readableStream.on('end', () => { console.log('File reading completed successfully.'); }); // Handle errors readableStream.on('error', (err) => { attempts++; console.error(`Attempt ${attempts} failed:`, err.message); if (attempts < retries) { console.log(`Retrying... (${retries - attempts} attempts left)`); attemptRead(); // Retry reading the file } else { console.error('Max retries reached. Giving up.'); readableStream.destroy(); // Close the stream and release resources } }); } attemptRead(); // Start the first attempt } // Usage createReadStreamWithRetry('./example.txt', 3); // File exists createReadStreamWithRetry('./fileNotExists.txt', 3); // File does not exist
The above function createReadStreamWithRetry
reads a file using a Node.js readable stream and incorporates a retry mechanism to handle potential errors during file access. If an error occurs, it retries reading the file a specified number of times before closing the stream.
By implementing a retry mechanism, we can make our application more reliable and stable.
Streams aren’t just for handling large data flows, they’re also a way to create modular, reusable code. Think of them as LEGO bricks for data workflow: small components that snap together to create powerful pipelines. Each stream handles a single responsibility, making our code easier to debug, test, and extend.
Here is an example that reads a file, transforms its content, compresses it, and writes the result — all in a memory-efficient stream:
const fs = require('fs'); const zlib = require('zlib'); const { Transform } = require('stream'); // 1. Create stream components const readStream = fs.createReadStream('input.txt'); // Source: Read file const writeStream = fs.createWriteStream('output.txt.gz');// Destination: Write compressed file // 2. Transform stream: Convert text to uppercase const upperCaseTransform = new Transform({ transform(chunk, _, callback) { this.push(chunk.toString().toUpperCase()); // Modify data callback(); } }); // 3. Compression stream: Gzip the data const gzip = zlib.createGzip(); // 4. Assemble the pipeline readStream .pipe(upperCaseTransform) // Step 1: Transform text .pipe(gzip) // Step 2: Compress data .pipe(writeStream); // Step 3: Write output
This chain of stream operations, connected by pipes, showcases how simple, reusable components can be combined to build complex data processing pipelines. This is just a taste of what you can achieve with streams. The possibilities are endless.
When using chained .pipe()
calls, errors in intermediate streams (like gzip
or upperCaseTransform
) won’t propagate to the final destination stream’s error handler. This can lead to uncaught exceptions, resource leaks, and application crashes. Let’s explore the problem and solutions in detail.
Here’s an example of a flawed implementation that misses intermediate errors:
const fs = require('fs'); const zlib = require('zlib'); const { Transform } = require("stream"); const readStream = fs.createReadStream('input.txt'); const destination = fs.createWriteStream('output.txt.gz'); const gzip = zlib.createGzip(); const upperCaseTransform = new Transform({ transform(chunk, encoding, callback) { this.push(chunk.toString().toUpperCase()); callback(); }, }); // Flawed implementation - misses intermediate errors readStream .pipe(upperCaseTransform) .pipe(gzip) .pipe(destination) .on('error', (err) => { // Only catches destination errors console.error('Pipeline failed:', err); destination.close(); }) .on('finish', () => { console.log('Pipeline succeeded!'); });
This approach fails because errors in intermediate streams like upperCaseTransform
or gzip
won’t propagate to the final .on('error')
handler. The unhandled errors could crash the entire Node.js process. Furthermore, resources like file descriptors or memory buffers might not be properly released without explicit error handling.
To fix the issue, we can attach individual error handlers to every stream:
// Proper error handling readStream .on('error', (err) => { console.error('Read error:', err); readStream.close(); }) .pipe(upperCaseTransform) .on('error', (err) => { console.error('Transform error:', err); upperCaseTransform.destroy(); }) .pipe(gzip) .on('error', (err) => { console.error('Gzip error:', err); gzip.destroy(); }) .pipe(destination) .on('error', (err) => { console.error('Write error:', err); destination.close(); }) .on('finish', () => { console.log('Pipeline succeeded!'); });
The above code will handle errors for each stream, but the repetitive error handlers are not ideal.
A cleaner approach is to use the pipeline
method. It automatically propagates errors from any stream to a single error handler and ensures proper cleanup:
const fs = require('fs'); const zlib = require('zlib'); const { pipeline } = require('stream'); // 1. Create stream components const source = fs.createReadStream('input.txt'); const gzip = zlib.createGzip(); const destination = fs.createWriteStream('output.txt.gz'); // 2. Connect streams using pipeline pipeline( source, // Read from file gzip, // Compress data destination, // Write to archive (err) => { // Unified error handler if (err) { console.error('Pipeline failed:', err); // Optional: Add retry logic here } else { console.log('Compression successful!'); } } );
In the above example, errors in any stream (source
, gzip
, or destination
) are passed to the error-handling callback function. We ensure streams are closed even on failure, and avoid repeated error handlers.
Node.js readable streams are more than just a tool — they’re a core pattern for building efficient, scalable applications.
In this guide, we explored how readable streams process data in small chunks, manage data flow with paused/flowing modes, handle errors, and ensure resource cleanup. We also discussed chaining, transforming, and piping streams like modular components. Whether parsing terabytes of logs or streaming live sensor data, readable streams provide an efficient way to handle data.
The code snippets in the article can be found here. For more details and best practices, refer to the Node.js Stream API documentation.
Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowManage state in React using URL parameters for better performance, SEO, and accessibility while enabling shareable and server-rendered application states.
UI libraries like React Native Paper and React Native Elements offer pre-developed components that help us deliver our React Native projects faster.
Although Docker remains the dominant platform for containerization and container management, it’s good to know about different tools that may work better for certain use cases.
Add to your JavaScript knowledge of shortcuts by mastering the ternary operator, so you can write cleaner code that your fellow developers will love.