Handle Node.js Stream Backpressure

In Node.js applications, writing data to a slow writable stream faster than it can process leads to high memory usage and potential crashes, a phenomenon known as backpressure. This article explains how backpressure occurs in Node.js streams and provides practical, efficient solutions to handle it, such as using the .pipe() method, listening to the 'drain' event, and utilizing the robust stream.pipeline() utility.

Understanding Backpressure in Node.js

Backpressure occurs when the data producer (readable stream) sends data faster than the data consumer (writable stream) can process it. When this happens, Node.js buffers the incoming data in system memory. If the buffer continues to grow, it can lead to high memory consumption and eventually crash the application with an “Out of Memory” error.

To manage this, the writable.write(chunk) method returns a boolean value: * true: The internal buffer is below the highWaterMark threshold, and you can safely continue writing. * false: The buffer limit has been reached. You should stop writing data until the stream signals that it is ready.


Solution 1: Use stream.pipe()

The easiest and most common way to handle backpressure is by using the .pipe() method. This method automatically manages the data flow between the readable and writable streams. It pauses the readable stream when the writable stream’s buffer is full and resumes it once the buffer has cleared.

const fs = require('fs');

const readable = fs.createReadStream('large-file.txt');
const writable = fs.createWriteStream('destination.txt');

// pipe automatically handles backpressure
readable.pipe(writable);

Solution 2: Use stream.pipeline() for Better Error Handling

While .pipe() manages backpressure automatically, it does not handle stream errors or close streams properly if one fails. The stream.pipeline() utility solves this by handling backpressure automatically while also offering clean error handling and resource cleanup.

const { pipeline } = require('stream');
const fs = require('fs');

const readable = fs.createReadStream('large-file.txt');
const writable = fs.createWriteStream('destination.txt');

pipeline(readable, writable, (err) => {
  if (err) {
    console.error('Pipeline failed:', err);
  } else {
    console.log('Pipeline completed successfully.');
  }
});

Solution 3: Manual Backpressure Handling with the ‘drain’ Event

If you are generating data dynamically (for example, in a loop) and writing directly to a writable stream without a readable stream source, you must handle backpressure manually.

To do this, you must pause writing when writable.write() returns false, and wait for the 'drain' event to fire before writing more data.

const fs = require('fs');
const writer = fs.createWriteStream('output.txt');

function writeOneMillionTimes(writer, data, encoding, callback) {
  let i = 1000000;
  
  function write() {
    let ok = true;
    do {
      i--;
      if (i === 0) {
        // Last write, trigger the callback when finished
        writer.write(data, encoding, callback);
      } else {
        // Write data and check if we should continue (true) or wait (false)
        ok = writer.write(data, encoding);
      }
    } while (i > 0 && ok);
    
    if (i > 0) {
      // Stopped writing because of backpressure.
      // Wait for the 'drain' event to resume.
      writer.once('drain', write);
    }
  }
  
  write();
}

writeOneMillionTimes(writer, 'some data\n', 'utf-8', () => {
  console.log('Finished writing all data safely.');
});

Solution 4: Using Async Iterators (Modern Approach)

With modern Node.js, you can consume readable streams and write to writable streams using for await...of loops. This approach natively respects backpressure because the loop waits for the write operation to resolve before fetching the next chunk.

const fs = require('fs');
const { finished } = require('stream/promises');

async function writeWithAsyncIterator(readable, writable) {
  for await (const chunk of readable) {
    if (!writable.write(chunk)) {
      // Handle backpressure by waiting for the drain event
      await new Promise((resolve) => writable.once('drain', resolve));
    }
  }
  writable.end();
  await finished(writable);
}

const readable = fs.createReadStream('large-file.txt');
const writable = fs.createWriteStream('destination.txt');

writeWithAsyncIterator(readable, writable)
  .then(() => console.log('Writing finished.'))
  .catch((err) => console.error('Writing failed:', err));