Node.js streams can overwhelm your consumers.

Let’s see what that looks like. Imagine you have a server that’s supposed to read a large file and send its contents over HTTP. The fs.createReadStream is a producer, and the response object (which is a Writable stream) is a consumer.

const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
  const readableStream = fs.createReadStream('large_file.txt'); // Fast producer
  readableStream.pipe(res); // Slow consumer (network latency, client buffering)
});

server.listen(3000, () => {
  console.log('Server listening on port 3000');
});

If large_file.txt is huge and the client receiving the data is slow (e.g., a mobile device on a spotty connection), the readableStream will keep reading data from the file and pushing it to the res stream. The res stream can’t write it out to the network fast enough. This is where backpressure comes in.

The Core Problem: Unbounded Buffering

When a producer writes data to a consumer faster than the consumer can process it, the producer doesn’t stop. By default, it keeps churning out data, and this data has to go somewhere. In Node.js streams, this "somewhere" is typically an internal buffer. If this buffer grows indefinitely, it will eventually consume all available memory, leading to a crash. The producer is essentially drowning the consumer in data.

How Streams Handle Backpressure

Node.js streams are designed with this in mind. When a stream is flowing data from a readable stream to a writable stream (using pipe), the writable stream signals back to the readable stream when it’s getting full. This signal is the "backpressure."

The pipe() method is smart. When the Writable stream’s internal buffer reaches a certain high-water mark, it pauses the Readable stream. This pause is communicated by the Writable stream emitting a drain event after it has processed enough data to make space. The Readable stream, upon receiving this drain event, resumes emitting data.

Common Causes of Backpressure Issues

  1. Slow Consumers: This is the most classic cause. Your network, a database write operation, an external API call, or even synchronous processing within your application can be slower than your data source.

    • Diagnosis: Monitor the memory usage of your Node.js process. If it’s constantly increasing and eventually crashes with an out-of-memory error, you likely have a backpressure problem. You can also use tools like node-clinic to visualize stream activity and identify bottlenecks.
    • Fix: Implement explicit backpressure handling. Instead of just readable.pipe(writable), you need to manage the flow.
      const http = require('http');
      const fs = require('fs');
      
      const server = http.createServer((req, res) => {
        const readableStream = fs.createReadStream('large_file.txt');
      
        readableStream.on('data', (chunk) => {
          // Attempt to write the chunk. If it returns false, the buffer is full.
          if (!res.write(chunk)) {
            // Pause the readable stream.
            readableStream.pause();
            // When the writable stream has flushed its buffer, it will emit 'drain'.
            res.once('drain', () => {
              // Resume the readable stream.
              readableStream.resume();
            });
          }
        });
      
        readableStream.on('end', () => {
          res.end(); // Signal the end of the response.
        });
      
        readableStream.on('error', (err) => {
          console.error('Readable stream error:', err);
          res.statusCode = 500;
          res.end('Server error');
        });
      
        res.on('error', (err) => {
          console.error('Response stream error:', err);
          // The client likely closed the connection.
          readableStream.destroy(); // Clean up the readable stream.
        });
      });
      
      server.listen(3000, () => {
        console.log('Server listening on port 3000');
      });
      
    • Why it works: This manual approach explicitly checks the return value of res.write(). When false is returned, it means the res stream’s internal buffer is full, and we manually pause the readableStream. We then wait for the drain event, which signifies that the res stream has successfully written its buffered data to the network, freeing up space. Once drain is emitted, we resume the readableStream.
  2. Synchronous Operations in the Consumer: If your consumer stream’s _write method (or any function it calls) performs synchronous, blocking operations, it can’t process incoming data quickly enough, leading to buffer buildup.

    • Diagnosis: Use Node.js’s built-in profiler or tools like clinic to identify synchronous code paths that are taking a long time within your stream’s write operation.
    • Fix: Move any blocking I/O or long-running synchronous computations out of the stream’s _write method. Use process.nextTick(), setImmediate(), or asynchronous APIs to defer the work.
      // Example: Imagine a custom Writable stream that does some sync processing
      const { Writable } = require('stream');
      
      class SlowSyncConsumer extends Writable {
        constructor(options) {
          super(options);
          this.buffer = [];
        }
      
        _write(chunk, encoding, callback) {
          // This is the problematic synchronous operation
          const processedData = this.syncProcess(chunk.toString());
          this.buffer.push(processedData);
          console.log(`Processed: ${processedData}`);
      
          // In a real scenario, this syncProcess might block for a long time.
          // If it takes too long, backpressure will build up.
          // For demonstration, we'll just call callback immediately.
          // If this were truly slow, you'd need to yield or use async.
          callback();
        }
      
        syncProcess(data) {
          // Simulate a slow synchronous operation
          const start = Date.now();
          while (Date.now() - start < 1) { /* busy wait */ }
          return data.toUpperCase();
        }
      }
      
      const readable = require('stream').Readable;
      const reader = new readable({
        read() {
          this.push('hello ');
          this.push('world\n');
          this.push(null); // End of stream
        }
      });
      
      const consumer = new SlowSyncConsumer();
      reader.pipe(consumer);
      
    • Why it works: By ensuring that the _write method (and any functions it calls) completes quickly and calls its callback promptly, you allow the stream to process data efficiently. If truly long-running synchronous work is needed, it must be offloaded to worker threads or handled asynchronously.
  3. High-Water Mark Too Low (Less Common for Built-ins): While less common with standard Node.js streams like fs.createReadStream and http.ServerResponse, custom streams or specific configurations might have a low highWaterMark. This means they signal "full" prematurely, causing the readable stream to pause and resume frequently, leading to inefficient flow.

    • Diagnosis: Examine the highWaterMark option of your Readable and Writable streams. If it’s set unusually low, it might be contributing to frequent pausing.
    • Fix: Increase the highWaterMark for the Writable stream or adjust the readableObjectMode if dealing with objects.
      const { Readable, Writable } = require('stream');
      
      // Custom readable stream with a large chunk size
      const readable = new Readable({
        highWaterMark: 64 * 1024 // 64KB default is 16KB
      });
      
      let i = 0;
      readable._read = () => {
        if (i < 10) {
          readable.push(Buffer.from(`Chunk ${i++}\n`));
        } else {
          readable.push(null);
        }
      };
      
      // Custom writable stream with a larger buffer capacity
      const writable = new Writable({
        highWaterMark: 128 * 1024 // Default is 16KB
      });
      
      writable._write = (chunk, encoding, callback) => {
        console.log(`Writing: ${chunk.toString().trim()}`);
        // Simulate some processing time
        setTimeout(() => {
          callback();
        }, 50);
      };
      
      readable.pipe(writable);
      
    • Why it works: A higher highWaterMark allows the Writable stream to buffer more data before signaling that it’s full. This reduces the frequency of pausing and resuming the Readable stream, leading to a more consistent and efficient data flow, especially for large amounts of data.
  4. Unnecessary Buffering in Custom Logic: If you’re manually collecting data in a custom readable or writing data in a custom writable without respecting the stream’s backpressure signals, you’ll create similar problems.

    • Diagnosis: Review your custom stream implementations. Are you accumulating data in memory indefinitely? Are you calling push() or write() without checking return values or handling drain?
    • Fix: Adhere to the stream API. For Readable streams, use push() and return false if the consumer is not ready (though this is less common as pipe handles most of it). For Writable streams, always call the callback when you’re done processing a chunk and handle drain.
    • Why it works: Following the stream contract ensures that the internal mechanisms of Node.js can manage the flow correctly.
  5. Object Mode Issues: When dealing with streams in objectMode, the highWaterMark is typically 16. If you’re pushing many objects rapidly without the consumer keeping up, backpressure can still occur, though the buffering unit is an object, not bytes.

    • Diagnosis: Monitor the number of objects being processed versus the number of objects buffered in your custom streams.
    • Fix: Increase the highWaterMark for object streams or ensure your consumer can process objects at a comparable rate.
      const { Readable, Writable } = require('stream');
      
      const readableObjects = new Readable({
        objectMode: true,
        highWaterMark: 50 // Increase buffer for objects
      });
      
      let objCount = 0;
      readableObjects._read = () => {
        if (objCount < 100) {
          readableObjects.push({ id: objCount++ });
        } else {
          readableObjects.push(null);
        }
      };
      
      const writableObjects = new Writable({
        objectMode: true,
        highWaterMark: 50
      });
      
      writableObjects._write = (obj, encoding, callback) => {
        console.log('Processing object:', obj.id);
        // Simulate work
        setTimeout(callback, 10);
      };
      
      readableObjects.pipe(writableObjects);
      
    • Why it works: Increasing the highWaterMark in object mode allows the stream to hold more objects in its buffer before pausing the producer, mitigating backpressure for object-based streams.

The Next Hurdle: Error Handling

Once you’ve mastered backpressure, you’ll inevitably face robust error handling. What happens when a stream encounters an error after you’ve set up your backpressure logic? You need to ensure errors are propagated correctly and that resources are cleaned up.

Want structured learning?

Take the full Nodejs course →