back

Working with files in Node.js (Part 1)

6 min read

Node.js includes several core modules, one of the most commonly used is the fs module - short for “File System”. This module enables developers to interact with the file system, allowing to read, write and modify files directly within Node.js applications. In this article, we’ll explore how to handle files both synchronous and asynchronous methods provided by the fs module.

Operating with file synchronously

To get started, let’s create a sample file that we can read and manipulate. You can either create a file manually or use the following command in your Terminal.

echo Hello World > hello.txt

Next, let’s create the script named read-write-sync.js that will read and modify the content of a file. You can do this with the command below.

touch read-write-sync.js

In this example, the fs module will be imported from the core Node.js File System module. Similarly, the core path module provides APIs for working with file and directory paths. we’ll synchronously read the file called hello.txt. Then, we use the fs.readFileSync() function to read the file. We pass the file path and the encoding UTF-8 as the parameter to this function. This encoding parameter is optional. When the encoding parameter is omitted, the function will automatically return a Buffer object.

const fs = require("fs");
const path = require("path");

const filePath = path.join(process.cwd(), "hello.txt");
const contents = fs.readFileSync(filePath, "utf8");
console.log("File Contents", contents);

The process.cwd() method is a function of the global process object that provides the current working directory of the Node.js process. This program expects that the hello.txt file is located in the same directory as the program.

At this point, we can modify the file’s content and convert the text into uppercase. We use the writeFileSync() function to modify the file. To manipulate of the file contents, we use the toUpperCase() string method to transform the content. We pass the two parameters to the fs.writeFileSync() function. The first parameter is the file path we would like to modify and the second parameter is the updated file contents. We’ll also add a log statement afterward indicating that the file has been modified.

const uppercase = contents.toUpperCase();

fs.writeFileSync(filePath, uppercase);
console.log("File updated.");

Next, we can execute the program using the following script.

node read-write-sync.js

To verify the contents we updated, your can either open the file or use the cat command in your Terminal to show the contents of hello.txt.

cat hello.txt
HELLO WORLD

Finally, we have a program that will read the contents of hello.txt and transform the content into uppercase.

However, the synchronous methods used here block the event loop until the operation is complete. Node.js enables the non-blocking I/O model which we can perform operations to be asynchronous. So, there are three ways to handle asynchronous code in Node.js: callbacks, Promises and async/await syntax. In this section, we’ll use callbacks to handle files asynchronously.

Operating with files asynchronously

Node.js is designed around a non-blocking, asynchronous I/O model. The asynchronous versions of file operations enable the program to keep executing while waiting for operations to complete. In the earlier program, the file was written using the synchronous functions available in the fs module.

const fs = require("fs");
const path = require("path");

const filePath = path.join(process.cwd(), "hello.txt");
const contents = fs.readFileSync(filePath, "utf-8");
console.log("File Contents", contents);

const upperContents = contents.toUpperCase();
fs.writeFileSync(filePath, upperContents);
console.log("File Updated.");

This means that the program was blocked while waiting for the readFileSync() and writeFileSync() operations to complete. This program can be rewritten to make use of asynchronous APIs. The asynchronous version of readFileSync() is readFile() while writeFileSync() corresponds to writeFile(). The asynchronous functions require a callback function to be passed to them. The callback function contains the code that we want to be executed once the asynchronous task is completed.

Let’s rewrite our previous program using the asynchronous functions and callbacks.

const fs = require("fs");
const path = require("path");

const filePath = path.join(process.cwd(), "hello.txt");
fs.readFile(filePath, "utf8", (err, contents) => {
  if (err) {
    return console.log("Failed to read file: ", err);
  }

  console.log("File Contents", contents);

  const upperContents = contents.toUpperCase();
  fs.writeFile(filePath, upperContents, (err, contents) => {
    if (err) {
      return console.log("Failed to write file: ", err);
    }

    console.log("File Updated.");
  });
});

Now, we have implemented an asynchronous function that invokes another asynchronous function. However, it is not recommended to use too many nested callbacks as it can negatively impact the readability of the code. Consider the following to see how having too many nested callbacks impedes the readability of the code which is known as “callback hell”.

fs.readFile(filePath, "utf8", (err, data) => {
  if (err) return;
  fs.writeFile(filePath, data.toUpperCase(), (err) => {
    if (err) return;
    fs.appendFile(filePath, "\nUpdated!", (err) => {
      if (err) return;
      console.log("All done!");
    });
  });
});

There are some approaches that can be taken to avoid too many nested callbacks. One approach to split callbacks into explicity named functions. In our file, we can extract writeFile() within its own named function called updateFile().

const fs = require("fs");
const path = require("path");

const filePath = path.join(process.cwd(), "hello.txt");
fs.readFile(filePath, "utf8", (err, contents) => {
  if (err) {
    return console.log("Failed to read file: ", err);
  }

  console.log("File Contents", contents);

  const upperContents = contents.toUpperCase();
  updateFile(filePath, contents);
});

function updateFile(filePath, contents) {
  fs.writeFile(filePath, upperContents, (err) => {
    if (err) {
      return console.log("Failed to write file: ", err);
    }

    console.log("File Updated.");
  });
}

In this article, we explored how to work with the Node.js fs module to read and write files using both synchronous and asynchronous approaches. While callbacks based asynchronous are powerful, they can quickly become difficult to manage in complex applications. As a next step, we’ll move beyond callbacks and explore a cleaner, more modern approach to synchronous file handling using the Promise APIs. This allows us to write more readable and maintainable code using async/await syntax. We’ll explore this approach in detail in the next article: Working with files in Node.js (Part 2).