Read and Write Large Files with Node fs.promises

Working with large files in Node.js can easily overwhelm system memory if not handled correctly. This article explains how to safely read and write massive files using the Node.js fs.promises API, exploring why standard buffer-based methods fail and how to implement memory-efficient streaming and chunk-based processing alternatives.

The Problem with Standard fs.promises Methods

Methods like fs.promises.readFile() and fs.promises.writeFile() are convenient but dangerous for massive files. They load the entire file into memory (V8 heap) all at once. If you try to read a 4 GB file on a system with a 2 GB Node.js default heap limit, your application will crash with an “Out of Memory” error.

To process large files safely, you must handle them in smaller, manageable chunks. The fs.promises API provides FileHandle objects that allow you to read and write files incrementally.


Safe Reading with FileHandles and Async Iterators

Instead of loading the entire file into memory, you can open the file using fs.promises.open() and read it sequentially using an async iterator. This ensures that only a small chunk of the file resides in memory at any given time.

import { open } from 'fs/promises';

async function readLargeFile(filePath) {
  let fileHandle;
  try {
    // Open the file in read-only mode
    fileHandle = await open(filePath, 'r');

    // Create a read stream from the file handle
    const stream = fileHandle.createReadStream();

    for await (const chunk of stream) {
      // Process each chunk (Buffer) here safely
      console.log(`Received chunk of size: ${chunk.length} bytes`);
    }
  } catch (error) {
    console.error('Error reading file:', error);
  } finally {
    // Always close the file handle to prevent memory leaks
    if (fileHandle) {
      await fileHandle.close();
    }
  }
}

Safe Writing with Stream Pipelines

When writing massive amounts of data, writing everything at once will also exhaust your memory. Using stream/promises alongside fs.promises allows you to pipe data safely. Node’s pipeline automatically manages “backpressure,” preventing data from buffering in memory if the disk write speed cannot keep up with the data source.

import { open } from 'fs/promises';
import { pipeline } from 'stream/promises';
import { Readable } from 'stream';

async function writeLargeFile(filePath, dataSourceIterator) {
  let fileHandle;
  try {
    // Open the file in write mode
    fileHandle = await open(filePath, 'w');

    // Create a readable stream from your data source generator
    const readStream = Readable.from(dataSourceIterator);
    const writeStream = fileHandle.createWriteStream();

    // Safely pipe the data to the destination file
    await pipeline(readStream, writeStream);
    console.log('File write completed successfully.');
  } catch (error) {
    console.error('Error writing file:', error);
  } finally {
    if (fileHandle) {
      await fileHandle.close();
    }
  }
}

// Example data generator yielding chunks of data
async function* dataGenerator() {
  for (let i = 0; i < 1000000; i++) {
    yield `Line ${i}: This is some data to write to our massive file.\n`;
  }
}

Processing and Transforming Large Files

You can combine reading, transforming, and writing into a single memory-efficient pipeline. This approach is ideal for tasks like parsing massive CSV logs, modifying them, and writing them to a new location.

import { open } from 'fs/promises';
import { pipeline } from 'stream/promises';

async function transformLargeFile(sourcePath, destinationPath) {
  let sourceHandle;
  let destHandle;

  try {
    sourceHandle = await open(sourcePath, 'r');
    destHandle = await open(destinationPath, 'w');

    const readStream = sourceHandle.createReadStream();
    const writeStream = destHandle.createWriteStream();

    await pipeline(
      readStream,
      async function* (source) {
        for await (const chunk of source) {
          // Convert chunk to string, uppercase it, and yield
          const transformed = chunk.toString().toUpperCase();
          yield transformed;
        }
      },
      writeStream
    );

    console.log('File transformation complete.');
  } catch (error) {
    console.error('Transformation failed:', error);
  } finally {
    if (sourceHandle) await sourceHandle.close();
    if (destHandle) await destHandle.close();
  }
}