Working with files in Node.js (Part 2)
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 theprocess.argv[0]
is the path of thenode
binary that is running. The second element is the path of the file we’re executing. The third command-line argumentprocess.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.