back

Working with files in Node.js (Part 2)

7 min read

We’ve explored how to handle file operations using the Node.js fs module through both synchronous and callback-based asynchronous methods in the first part of this article. While functional, callback patterns can become cumbersome in larger codebases, leading to deeply nested and harder to maintain code.

In this article, we’ll focus to modern approach, Promise-based file operations using the fs/promises API introduced in Node.js at v10.0.0. This approach empowers developers to handle file ausing async/await syntax, streamlining asynchronous workflows and aligning with current best practices in Node.js development.

Using the fs Promises API

The fs/promises module provides an alternative approach of file system methods that return Promise objects rather than callbacks. A Promise is an object that is used to represent the completion of an asynchronous operation and allow for more intuitive flow control. A promise object operates in one of three following states:

  • Pending
  • Fulfilled
  • Rejected

A Promise will initially be in the pending state and will remain pending until it becomes either fulfilled when the task has successfully completed or rejected when the task fails.

Let’s rewrite the same behavior of the program from the first part of this article. using the fs/promises API.

const fs = require("fs/promises");
const path = require("path");
const filePath = path.join(process.cwd(), "hello.txt");

async function run() {
  try {
    const contents = await fs.readFile(filePath, "utf8");
    console.log("contents", contents);
  } catch (error) {
    console.log("error: ", error);
  }
}

run();

We import the fs/promises module instead of the standard fs module. The run() function is marked async that allows use to use the await keyword inside it. We use await fs.readFile(...) to asynchronously read the contents of the file. If an error occurs, it’s caught in the catch block and logged appropriately.

Next, we can execute the program by running the following script in your Terminal.

node read-write-sync.js

The program will read the file content and display.

contents: HELLO WORLD

Now, we’ve learned how we can interact with files using the fs Promises API. This approach provides a more robust and maintainable structure for file operations especially as Node.js applications scale or integrate with other asynchronous workflows.

Fetching metadata

The fs module generally provides APIs that are modeled around Portable Operating System Interface (POSIX) functions. It includes APIs that facilitate the reading of directories and file metadata.

To better understanding, let’s create a small program that returns information about a file using functions provided by the fs module. We’ll also need to create a file to read and a file for our program.

touch metadata.js
touch file.txt

As the above example, we need to import necessary core modules. Next, we need the program to be able to read the file name as a command-line argument. We can use process.argv[2] to read the file argument.

const fs = require("fs");
const file = process.argv[2];

The process.argv is a property of the global process object that returns an array containing the arguments that were passed to the Node.js process. The first element of the process.argv[0] is the path of the node binary that is running. The second element is the path of the file we’re executing. The third command-line argument process.argv[2] is the filename we passed to the Node.js process.

Now, we need to create a function called printMetadata(). In the function, we should also catch an error and ouput an error message for passing non-existent file path to the program.

function printMetadata(file) {
  try {
    const fileStats = fs.statSync(file);
    console.log("File Stats:", fileStats);
  } catch (error) {
    console.log("Error reading file path:", error);
  }
}

printMetadata(file);

You can now execute the program by passing it the ./file.txt argument.

node metadata.js ./file.txt

The following output is expected to see.

File Stats: Stats {
  dev: 16777229,
  mode: 33188,
  nlink: 1,
  uid: 501,
  gid: 20,
  rdev: 0,
  blksize: 4096,
  ino: 148719286,
  size: 12,
  blocks: 8,
  atimeMs: 1743477996872.2944,
  mtimeMs: 1741446130509.5627,
  ctimeMs: 1741446130509.5627,
  birthtimeMs: 1741440763719.097
}

You can try adding more text to file.text, saving the file and then re-running the program. It observes that the size and mtimeMs values have been updated.

Watching files

The Node.js fs module provides built-in capabilities to monitor changes to files and directories. This is particularly useful for implementing features like reloads, audit logging and automation workflows when files are created, modified or deleted.

Let’s create a file that we want to watch. You can either create a file manually or use the following command in your Terminal.

echo Hello World > file.txt

Then, create a file that will contain our watcher program.

touch watching-file.js

We need to import the required core Node.js modules. Node.js provides the fs.watchFile() method which polls a file changes at regular intervals. This function accepts three parameters: the file path, an optional configuration object and a listener callback function executed whenever the file is modified. We also make the timestamp more readable using Intl.DateTimeFormat object. It is the built-in JavaScript utility to manipulate dates and times.

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

const filePath = path.join(process.cwd(), "file.txt");

fs.watchFile(filePath, (current, previous) => {
  const formattedTime = new Intl.DateTimeFormat("en-GB", {
    dateStyle: "full",
    timeStyle: "long",
  }).format(current.mtime);

  return console.log(`${file} updated at ${formattedTime}`);
});

Now, you can execute the program in your Terminal with the following command.

node watching-file.js

Then, open file.txt in your editor, make some changes and save. Each time you save the file, the script logs a message indicating the update.

./file.txt updated Saturday, 18 November 2024 at 11:37:30 GMT+6:30.

The listener function supplied to the watchFile() function will execute every time a change is detected. The listener function’s arguments, current and previous are both Stats objects, representing the current and previous state of the file. This allows precise tracking of changes such as modification times, size differences and more.

In addition to watchFile(), the Node.js offers watch() which hooks into the underlying operating system’s file notification system rather than polling for changes. This results in more efficient and immediate change detection, particularly for high-frequency updates or directory monitoring. However, it is not consistent across various platforms because it is underlying operating system’s method of notifying file system changes.

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

const filePath = path.join(process.cwd(), "file.txt");

fs.watch(filePath, (eventType, filename) => {
  const formattedTime = new Intl.DateTimeFormat("en-GB", {
    dateStyle: "full",
    timeStyle: "long",
  }).format(new Date());

  return console.log(`${filename} updated at ${formattedTime}`);
});

The watch() function also accepts three parameters: filename, an array of options and a listener (callback) function that handles events. Unlike watchFile(), the listener function for watch() receives different arguments. The arguments to the listener function are eventType and trigger, where eventType is either change or rename and trigger is the file that triggered an event. This interface provides a more event-driven approach, it offers less detailed file metadata compared to the Stats objects provided by watchFile().

In the second part of this article, we’ve extended our exploration of Node.js file system operations by introducing modern, Promise-based workflows through the fs/promises API. This approach, when combined with async/await not only simplifies asynchronous control flow but also improves code readability, scalability and maintainability in complex applications. Also, we’ve explored how to retrieve file metadata and monitor file and directory changes. With the right tools and patterns in place, the Node.js fs module provides all the flexibility needed to build robust, filesystem-aware applications.