In the first tutorial, we explored how Node.js executes JavaScript using V8, libuv, the event loop, and the worker pool. In the second tutorial, we learned how to organize an application into modules using CommonJS and ES Modules. Now we will connect those concepts to something every backend application eventually needs to do: interact with files on a computer. In this tutorial, we will learn about Node.js file system.
Imagine you are building a notes application. Users should be able to create notes, read them later, update their contents, and delete notes they no longer need. Alternatively, imagine a server that saves application settings, writes logs, processes uploaded documents, or reads configuration files during startup.
All these operations require your application to interact with the file system.
Node.js provides a built-in module called node:fs, along with its Promise-based interface, node:fs/promises.
But before memorizing methods such as readFile(), writeFile(), and unlink(), we should understand what a file operation actually involves.
Table of Contents
How does JavaScript access a file on disk? Why can some file operations block the event loop? What is the difference between a filename and a file descriptor? Why does reading a file sometimes return a string and sometimes return a Buffer?
We will answer these questions step by step, then build a practical file-handling program.
Part 1: How Can JavaScript Access a File on Your Computer?
1. JavaScript Does Not Directly Control Your Storage Device
Consider this line of code:
const message = await fs.readFile("notes.txt", "utf8");
It looks simple. We provide a filename, and JavaScript receives the contents.
But the JavaScript code is not directly controlling the SSD or hard drive. It asks Node.js to perform a file-system operation, and Node.js uses operating-system facilities to access the requested file.
A simplified view looks like this:
Your JavaScript Code
|
| fs.readFile("notes.txt")
v
Node.js
|
| Operating-system file APIs
v
OS Kernel
|
| Filesystem and storage management
v
RAM Cache / Storage Device
|
v
File Contents Returned to Node.js
|
v
JavaScript Receives the Result
The operating system manages access to storage, file permissions, open files, and the underlying file-system structures. It may serve data from memory caches rather than reading the physical storage device every time.
The key point is that Node.js provides JavaScript with an interface to operating-system file operations. We do not need to manipulate disk hardware ourselves.
2. What Are User Space and Kernel Space?
In a typical operating system, application code runs in user space, while the operating system kernel manages privileged resources in kernel space.
Your Node.js process runs in user space. When it requests access to a file, the operating system checks the request and performs the operation using its file-system and storage mechanisms.
These requests often involve system calls or operating-system APIs.
You do not need to understand the exact system calls to use fs, but this model explains why file access is not simply an ordinary JavaScript variable lookup.
Reading a value already held in a JavaScript variable is a memory operation. Reading a file may require filesystem work, permission checks, waiting for storage access, and transferring bytes into your process.
Those are fundamentally different operations.
Part 2: What Is a File, Really?
We usually picture a file as a named container holding data, such as notes.txt.
That is a useful mental model for application development, but it hides some important details.
On a typical filesystem, several pieces of information are associated with a file: its name and location, its contents, and metadata such as its size and permissions.
On Unix-like filesystems, the concept of an inode helps explain how these responsibilities are organized.
1. File Contents
The content is the actual data stored in the file.
For a text file, the bytes might represent something like:
Hello Node.js
For an image, PDF, video, or executable, those bytes represent binary data that requires an appropriate interpretation.
The filesystem may store that data across multiple storage blocks. The operating system and filesystem manage where the data belongs and how it is retrieved.
2. File Metadata and Inodes
On Unix-like filesystems, an inode is a filesystem data structure containing metadata about a file, including information such as its size, permissions, ownership, timestamps, and references to its data.
An inode generally does not store the human-readable filename itself. A directory provides the association between a filename and the corresponding filesystem object.
A simplified illustration:
Directory
|
+-- "notes.txt" ----> Inode / File Metadata
|
v
File Data
+-- "photo.png" ----> Inode / File Metadata
|
v
Image Data
This is a conceptual model for inode-based filesystems, not a universal description of every operating system. Windows filesystems, for example, use different internal structures.
Why should a Node.js developer care?
Because Node.js can ask the operating system about file metadata without needing to read the file’s entire contents.
For example:
const stats = await fs.stat("notes.txt");
console.log("Size:", stats.size);
console.log("Is file:", stats.isFile());
console.log("Last modified:", stats.mtime);
Here, stat() retrieves metadata. It does not ask JavaScript to load the entire file contents into a string.
3. Why Is Deleting a File Called unlink()?
In Node.js, one method for deleting a file is:
await fs.unlink("notes.txt");
On filesystems with directory entries and links, unlinking removes a name-to-file association. The underlying data may remain available while references or open handles still exist. The operating system determines when the associated storage can be reclaimed.
The filename does not have to be the same thing as the underlying file object. This is one reason the operating-system terminology is useful.
For everyday application development, remember that unlink() removes a file’s directory entry. It is not the method for deleting a directory tree.
For removing files and directories recursively, Node.js provides fs.rm() with appropriate options.
Part 3: What Happens When a File Is Opened?
Before a program can read or write a file, the operating system must establish access to it.
Conceptually, the lifecycle looks like this:
File Path
|
v
Open the File
|
v
Check Access and Mode
|
v
Obtain a File Descriptor
|
v
Read or Write
|
v
Close the File
The details vary across operating systems, but one concept matters particularly in Node.js: the file descriptor.
What Is a File Descriptor?
A file descriptor is a small integer used by a process on POSIX-style systems to refer to an open resource. Windows has a different underlying mechanism, but Node.js presents a similar abstraction through its APIs.
When you open a file using a low-level API, Node.js provides a file descriptor or a FileHandle object that represents one.
A descriptor is not the file’s content. It is a reference used to perform operations on an open file.
For example, the Promise API supports this:
const handle = await fs.open("notes.txt", "r");
try {
const content = await handle.readFile("utf8");
console.log(content);
} finally {
await handle.close();
}
Here, open() creates a handle for the file, and close() releases it.
A FileHandle is Node.js’s higher-level object around the underlying descriptor. This is why you often see handle or fileHandle in Promise-based code instead of managing numeric descriptors directly.
Why Must We Close Files?
Operating systems limit how many resources a process can keep open. If an application continually opens files without closing them, it can exhaust those resources and eventually encounter errors such as EMFILE, meaning there are too many open files for the process.
Always close a manually opened FileHandle, typically inside a finally block, so that the handle is closed even if an error occurs.
High-level operations such as readFile() manage their internal file resources for you. We will use those APIs first and return to FileHandle when we need more control.
Part 4: Introducing Node.js’s File System Module
Node.js provides the file-system module through two closely related entry points:
import * as fs from "node:fs";
and:
import * as fs from "node:fs/promises";
The first provides callback-based and synchronous APIs. The second provides Promise-based APIs.
Since we studied ES Modules in previous tutorial, the examples in this lesson will use ESM.
For the modern Promise-based approach, the import is:
import * as fs from "node:fs/promises";
We will use async and await because they make it straightforward to write asynchronous file-handling code in a readable sequence.
Why Are There Multiple API Styles?
Node.js supports three broad ways to perform common filesystem operations.
| API style | Example | Behavior |
|---|---|---|
| Synchronous | readFileSync() | Blocks the JavaScript thread until the operation completes |
| Callback-based | readFile() from node:fs | Completes asynchronously and reports the result through a callback |
| Promise-based | readFile() from node:fs/promises | Completes asynchronously and returns a Promise |
The names can be confusing because readFile() exists in both the callback-based and Promise-based modules. The import determines which version you use.
1. Synchronous File Operations
import { readFileSync } from "node:fs";
const content = readFileSync("notes.txt", "utf8");
console.log(content);
The synchronous operation blocks further JavaScript execution on the current thread until it completes.
This can be acceptable in a small command-line script or during carefully chosen startup work. But in a server handling multiple requests, a slow synchronous file operation can delay other JavaScript callbacks from running.
2. Callback-Based Operations
import { readFile } from "node:fs";
readFile("notes.txt", "utf8", (error, content) => {
if (error) {
console.error("Could not read file:", error.message);
return;
}
console.log(content);
});
The result is delivered through a callback. By convention, the first callback argument is an error and the next argument contains the result.
This is called an error-first callback.
3. Promise-Based Operations
import * as fs from "node:fs/promises";
async function readNotes() {
try {
const content = await fs.readFile("notes.txt", "utf8");
console.log(content);
} catch (error) {
console.error("Could not read file:", error);
}
}
readNotes();
The Promise-based API lets us use await and handle failures with try and catch.
Internally, Node.js’s asynchronous filesystem APIs use the underlying thread pool for many file operations. This helps keep filesystem work from blocking the main JavaScript event loop while the operation is pending.
However, await does not make the disk operation instantaneous, and it does not mean every part of the filesystem implementation executes as JavaScript in parallel. It lets the current asynchronous function pause while Node.js waits for the result, allowing other JavaScript work to proceed.
For this tutorial, we will primarily use node:fs/promises.
Part 5: Understanding Paths Before Reading Files
A path identifies where a file or directory is located.
For example:
project/
├── package.json
├── day03.mjs
└── data/
└── notes.txt
The file notes.txt lives inside the data directory.
You might try:
await fs.readFile("./data/notes.txt", "utf8");
This works only when the relative path resolves to the intended location.
An important Node.js detail is that relative filesystem paths are resolved from the process’s current working directory, available through process.cwd(). They are not automatically resolved relative to the JavaScript file containing the code.
You can inspect the current working directory like this:
console.log(process.cwd());
If the application is started from a different directory, the value can change.
When a script should access files relative to its own location, you can anchor its paths to the script instead.
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dataDir = path.join(__dirname, "data");
const notesPath = path.join(dataDir, "notes.txt");
Here, import.meta.url identifies the current ES Module as a URL. fileURLToPath() converts it into a filesystem path, and path.dirname() extracts the containing directory.
path.join() combines path segments using the appropriate platform-specific separator.
This approach makes the script’s file locations independent of the directory from which you launch Node.js.
Part 6: The Four Fundamental File Operations
Many applications use the same four operations when working with stored data: create, read, update, and delete. Together, these are commonly called CRUD operations.
Let’s use a text file to understand each one.
1. Create a Directory
Before saving a file, we might need a directory for our application’s data.
await fs.mkdir(dataDir, { recursive: true });
The mkdir() method creates a directory. With { recursive: true }, Node.js also creates missing parent directories and does not fail merely because the target directory already exists.
This is useful when initializing an application’s storage area.
2. Create or Write a File
Now create a notes file:
await fs.writeFile(notesPath, "My first Node.js note\n", "utf8");
writeFile() writes the supplied content to the target file. If the file does not exist, it is created. If it already exists, its contents are replaced by default.
That second behavior is important. The call above does not simply add a new line to the existing file. It replaces the file’s contents.
If your goal is to add content without discarding what is already present, use appendFile() instead.
3. Read a File
const content = await fs.readFile(notesPath, "utf8");
console.log(content);
The first argument identifies the file, and the second tells Node.js to decode the bytes as UTF-8 text.
Without an encoding, readFile() returns a Buffer, which represents raw bytes. We will examine this difference shortly.
4. Update a File
For example, we can append another note:
await fs.appendFile(notesPath, "Learning the Node.js filesystem\n", "utf8");
appendFile() adds the supplied content to the end of the file, creating it if necessary.
Use it when appending is the intended behavior. It is not a general-purpose way to safely update records in a shared database file, because concurrent writes and application-level consistency still need to be handled deliberately.
5. Delete a File
await fs.unlink(notesPath);
This removes the file’s directory entry. Once the operation succeeds, attempting to read the same path will ordinarily fail with ENOENT unless something creates another file at that location.
For directories, use rmdir() when removing an empty directory or rm() with suitable options when you need recursive removal. Modern Node.js versions no longer support the old recursive rmdir() option.
Part 7: Build a Complete File-Handling Program
Now let’s combine the concepts into a practical program.
Create this project structure:
node-fs-day3/
├── package.json
└── day03.mjs
We use .mjs, so the file is treated as an ES Module without needing a "type": "module" setting.
day03.mjs
import * as fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dataDir = path.join(__dirname, "data");
const notesPath = path.join(dataDir, "notes.txt");
const updatedPath = path.join(dataDir, "saved-notes.txt");
async function main() {
try {
// 1. Ensure the storage directory exists.
await fs.mkdir(dataDir, { recursive: true });
// 2. Create or replace the initial file.
await fs.writeFile(
notesPath,
"My first Node.js note.\n",
"utf8",
);
// 3. Append additional content.
await fs.appendFile(
notesPath,
"I am learning the filesystem module.\n",
"utf8",
);
// 4. Read the file.
const content = await fs.readFile(notesPath, "utf8");
console.log("File contents:\n" + content);
// 5. Inspect file metadata.
const stats = await fs.stat(notesPath);
console.log("Size in bytes:", stats.size);
console.log("Is a regular file:", stats.isFile());
// 6. Rename the file.
await fs.rename(notesPath, updatedPath);
console.log("File renamed successfully.");
// 7. Read the renamed file.
const updatedContent = await fs.readFile(updatedPath, "utf8");
console.log("Renamed file contents:\n" + updatedContent);
// 8. Delete the file.
await fs.unlink(updatedPath);
console.log("File deleted successfully.");
} catch (error) {
console.error("Filesystem operation failed:", error);
}
}
main();
Run it with:
node day03.mjs
The program creates a storage directory, writes a file, appends a line, reads its contents, checks metadata, renames the file, reads it again, and finally deletes it.
What Does await Do Here?
Notice that each operation uses await.
For example:
await fs.writeFile(notesPath, "My first note.\n", "utf8");
const content = await fs.readFile(notesPath, "utf8");
The second operation begins only after the first operation has completed successfully.
This sequencing matters. If we launched the write and read independently without coordinating them, the read might occur before the write has finished.
Because each Promise is awaited before moving to the next operation, the code expresses the intended order clearly.
Why Do We Use try and catch?
Filesystem operations can fail for many reasons. The file may not exist, the directory may be inaccessible, permissions may prevent writing, or the requested operation may be invalid.
A rejected Promise inside a try block transfers control to catch, allowing us to handle the error rather than letting the failure go unhandled.
In a real application, you would usually make the error-handling policy more specific. For example, a missing optional configuration file might be treated differently from a permission error or a disk failure.
Part 8: What Is a Buffer, and Why Do File Reads Sometimes Return One?
Files contain bytes. A text file is not physically stored as a JavaScript string. The bytes must be interpreted using an encoding before they become readable text.
Consider:
const content = await fs.readFile(notesPath);
console.log(content);
If no encoding is specified, the result is a Buffer.
A Buffer is a Node.js type for working with binary data. It is especially useful when dealing with images, PDFs, compressed files, network packets, and other content that should not immediately be decoded as text.
For example, these two operations have different return types:
const rawBytes = await fs.readFile(notesPath);
const text = await fs.readFile(notesPath, "utf8");
rawBytes is a Buffer, while text is a string.
You can convert a buffer to text explicitly:
console.log(rawBytes.toString("utf8"));
Why does this matter?
If your file contains ordinary UTF-8 text, decoding it into a string is convenient. But if you are processing an image or another binary format, treating its bytes as text can corrupt the interpretation of the data.
In next tutorial, we will explore Buffer in greater depth, including how bytes, hexadecimal values, and character encodings work.
Part 9: File Flags and Permissions
So far, we have used the default behavior of writeFile() and appendFile(). Filesystem operations can also accept flags that control how files are opened.
A flag influences whether the operation reads, writes, appends, or requires a file to be created exclusively.
| Flag | Meaning | Typical behavior |
|---|---|---|
'r' | Read | Opens an existing file for reading; fails if missing |
'r+' | Read and write | Opens an existing file without truncating it |
'w' | Write | Creates a file or truncates an existing file |
'wx' | Exclusive write | Creates a new file, failing if the target already exists |
'a' | Append | Creates a missing file or appends to an existing one |
'ax' | Exclusive append | Creates a file only if it does not already exist |
For example, if you want to create a file without overwriting an existing one:
try {
await fs.writeFile(
notesPath,
"New file content\n",
{ encoding: "utf8", flag: "wx" },
);
console.log("New file created.");
} catch (error) {
if (error.code === "EEXIST") {
console.log("The file already exists; it was not overwritten.");
} else {
throw error;
}
}
The 'wx' flag is useful when accidental replacement would be harmful. The exclusive creation behavior is handled by the underlying operating-system file-opening mechanism, rather than by a separate preliminary existence check.
Understanding File Permissions
When a file is created, Node.js can also accept a mode option to request access permissions on platforms that support POSIX-style permissions.
For example:
await fs.writeFile(
path.join(dataDir, "private.txt"),
"Private information\n",
{ encoding: "utf8", mode: 0o600 },
);
The octal mode 0o600 requests read and write access for the owner, with no permissions granted to the group or others under conventional POSIX permission semantics.
Common examples include:
0o600: Owner can read and write; group and others have no permissions.0o644: Owner can read and write; group and others can read.0o755: Owner can read, write, and execute; group and others can read and execute.
Actual permissions depend on the operating system, filesystem, and process umask. The mode option primarily affects a newly created file; it does not automatically change the permissions of an existing file.
On Windows, POSIX permission bits do not map directly to the same access-control model. Therefore, do not treat an octal mode as a complete, cross-platform security mechanism.
Part 10: Inspecting Files with stat()
Sometimes you need to know about a file without reading its contents.
For example, a file-upload service may need to inspect file sizes, or a cleanup process may need to identify old files.
Node.js provides stat() for retrieving filesystem metadata:
const stats = await fs.stat(notesPath);
console.log("Size:", stats.size);
console.log("Modified:", stats.mtime);
console.log("Is file:", stats.isFile());
console.log("Is directory:", stats.isDirectory());
The result is a Stats object with information such as file size, timestamps, and methods that help identify the kind of filesystem object.
For a symbolic link, stat() follows the link and reports information about its target. If you need information about the link itself, use lstat().
Remember that filesystem metadata is not always identical across operating systems. For example, timestamps and inode-related values can have different semantics depending on the filesystem.
The useful application-level lesson is that stat() retrieves metadata, whereas readFile() retrieves file contents.
Part 11: Working with Directories
A filesystem application often needs to list files, scan folders, or locate content recursively.
To list the entries in a directory:
const entries = await fs.readdir(dataDir);
console.log(entries);
By default, readdir() returns the entry names.
If you also need to determine whether each entry is a file, directory, or symbolic link, you can request Dirent objects:
const entries = await fs.readdir(dataDir, {
withFileTypes: true,
});
for (const entry of entries) {
if (entry.isFile()) {
console.log("File:", entry.name);
} else if (entry.isDirectory()) {
console.log("Directory:", entry.name);
} else if (entry.isSymbolicLink()) {
console.log("Symbolic link:", entry.name);
}
}
This is useful when building file browsers, cleanup tools, import processors, or directory scanners.
For recursive traversal, you can visit each directory and process its entries. Be deliberate about how symbolic links are handled: following them without tracking visited locations can lead to repeated traversal or loops. Security-sensitive directory traversal also needs to consider links and files changing while the scan is running.
Part 12: What Is a FileHandle, and When Should We Use One?
For many tasks, high-level methods such as readFile() and writeFile() are sufficient. They manage much of the lower-level work for you.
Sometimes, however, you need more control over how a file is accessed. Node.js’s Promise API supports open(), which returns a FileHandle.
For example, we can read only part of a file:
const handle = await fs.open(notesPath, "r");
try {
const buffer = Buffer.alloc(20);
const { bytesRead } = await handle.read(
buffer,
0,
buffer.length,
0,
);
console.log("Bytes read:", bytesRead);
console.log("Content:", buffer.subarray(0, bytesRead).toString("utf8"));
} finally {
await handle.close();
}
The call to Buffer.alloc(20) creates a buffer with space for 20 bytes. The read operation fills part or all of that buffer, and the result tells us how many bytes were actually read.
We then print only the portion containing the returned data. This matters because the final read may return fewer bytes than requested.
Unlike readFile(), this code provides control over the number of bytes read and the starting position.
It also introduces a resource-management responsibility: we must close the handle when finished. That is why we use try and finally.
This approach can be useful for files that require partial reads, careful positioning, or lower-level access. If you only need the complete contents of a small configuration or text file, readFile() is usually simpler.
For very large files, consider streams rather than loading the entire file into memory.
Part 13: Important Production Lessons
The basic API is straightforward, but reliable filesystem code requires a few additional considerations.
1. Avoid Checking Whether a File Exists Before Reading It
A tempting pattern looks like this:
await fs.access(notesPath);
const content = await fs.readFile(notesPath, "utf8");
The problem is that the file can change between the two operations. Another process could remove or replace it after the check but before the read.
This is a time-of-check to time-of-use (TOCTOU) race condition.
For ordinary read operations, it is usually better to attempt the read directly and handle the relevant error:
try {
const content = await fs.readFile(notesPath, "utf8");
console.log(content);
} catch (error) {
if (error.code === "ENOENT") {
console.log("The file does not exist.");
} else {
throw error;
}
}
Use an explicit existence check only when checking is genuinely the operation you need, not as a guarantee that a later operation will succeed.
2. Understand Common Filesystem Errors
Node.js exposes operating-system error codes that help identify what went wrong.
| Error code | Typical meaning |
|---|---|
ENOENT | A requested file or path does not exist |
EACCES | Access was denied by permissions |
EEXIST | A file already exists when an exclusive creation operation was requested |
EISDIR | An operation expected a file but received a directory |
EMFILE | The process has too many open file descriptors |
The precise error depends on the requested operation and operating system, but these codes are useful when writing error-handling logic.
Avoid treating all filesystem errors as equivalent. A missing optional file may be recoverable, while a disk or permission error may require the application to stop the operation.
3. Know When readFile() Is Not the Right Tool
The readFile() API reads the file contents into memory before returning them.
That is convenient for small text files and configuration files. But if a file is several gigabytes, loading all of it into one buffer can consume substantial memory and may cause the Node.js process to run out of memory.
For large files, use streams or controlled chunked reads so the application can process data incrementally.
We will cover streams in a later lesson. For now, remember that a convenient high-level API is not always the right choice for every file size.
Part 14: Safer File Writing: Why Replacing a File Needs Care
Consider this operation:
await fs.writeFile("database.json", JSON.stringify(data), "utf8");
By default, the operation replaces the existing file contents. If a process fails during a write, the target file may be left incomplete. A successful Promise means the filesystem operation completed according to the API, but it does not mean the file is protected against every possible crash or power loss.
This matters when writing important state, configuration, or generated data.
The Temporary-File-and-Rename Approach
One commonly used technique is to write a new version to a temporary file in the same directory and then rename it over the target.
Conceptually:
Current database.json
|
| Write replacement separately
v
database.json.tmp
|
| Finish writing
v
Rename
|
v
New database.json
The benefit is that readers do not normally see a partially written replacement at the target path while the temporary file is still being written.
Here is a simplified implementation:
import * as fs from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
export async function writeFileViaTempFile(
targetPath,
content,
) {
const directory = path.dirname(targetPath);
const baseName = path.basename(targetPath);
const tempPath = path.join(
directory,
`.${baseName}.${randomUUID()}.tmp`,
);
let handle;
try {
// Exclusive creation avoids accidentally reusing a temp file.
handle = await fs.open(tempPath, "wx", 0o600);
await handle.writeFile(content, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await fs.rename(tempPath, targetPath);
} catch (error) {
if (handle) {
await handle.close().catch(() => {});
}
await fs.unlink(tempPath).catch(() => {});
throw error;
}
}
This example uses a unique temporary filename, creates it exclusively, writes the contents, asks the operating system to synchronize the file’s data, closes the handle, and renames the temporary file.
There are important caveats. Rename behavior and atomicity depend on the filesystem and platform, and this pattern does not by itself guarantee complete durability through every power-loss scenario. Strong crash-durability requirements may also require synchronizing the containing directory and handling platform-specific behavior.
The practical lesson is to distinguish two goals: preventing readers from observing a partially replaced file, and guaranteeing that a completed update survives sudden system failure. They are related but not identical requirements.
Part 15: A Final Mental Model
Let’s put together the concepts from this lesson.
JavaScript Application
|
v
node:fs
|
v
Select an API style
/ | \
Sync Callback Promise
|
v
Async filesystem work
|
v
OS / Filesystem
|
v
File metadata
and file bytes
|
v
Result or error
|
v
Application logic
When you call fs.readFile(), the path identifies the file. Node.js requests the operation from the filesystem, manages the underlying work, and returns the result or an error.
If you supply an encoding, the bytes are decoded into text. Without one, the result is a Buffer. If you need only metadata, stat() can return information about the file without requiring you to load its contents into a JavaScript string.
If you need lower-level access, open() gives you a FileHandle, which you must close reliably. If you need to handle large files efficiently, streams or chunked reads can reduce memory pressure.
These ideas form the basis for working safely with the filesystem in Node.js.
Conclusion: From JavaScript to Real File Operations
Today, we moved beyond the surface-level use of readFile() and writeFile() to understand how Node.js interacts with the operating system.
We learned why file access involves filesystem APIs, how paths identify files, how metadata differs from file contents, and why file descriptors and file handles must be managed carefully. We compared synchronous, callback-based, and Promise-based operations before building a working program for creating, reading, updating, renaming, inspecting, and deleting files.
We also explored practical decisions: when to use Buffer, how file flags and permissions affect behavior, why checking for existence before reading can create a race condition, and why important file updates may require a temporary-file strategy.
The central idea is that the filesystem is an external resource, not simply another JavaScript variable. Operations can take time, fail, consume resources, and interact with changes made by other processes. Understanding these realities helps you write applications that are easier to debug and safer to operate.
| Method | Description |
access(path[, mode]) | Tests a user’s permissions for the file or directory. |
appendFile(path, data[, options]) | Appends data to a file, creating the file if it does not yet exist. |
chmod(path, mode) | Changes the file permissions (read/write/execute). |
chown(path, uid, gid) | Changes the ownership (user and group) of a file. |
copyFile(src, dest[, mode]) | Copies a file from the source path to the destination path. |
cp(src, dest[, options]) | Copies a file or entire directory structure (including subdirectories) to a destination. |
lchmod(path, mode) | Changes the permissions on a symbolic link (macOS only). |
lchown(path, uid, gid) | Changes the ownership of a symbolic link. |
link(existingPath, newPath) | Creates a new hard link to an existing file. |
lstat(path[, options]) | Retrieves information/statistics for a symbolic link itself, rather than the file it points to. |
lutimes(path, atime, mtime) | Changes the access and modification timestamps of a symbolic link. |
mkdir(path[, options]) | Creates a directory. Supports recursive creation via options. |
mkdtemp(prefix[, options]) | Creates a unique temporary directory using a specified prefix. |
open(path, flags[, mode]) | Opens a file and returns a FileHandle object for further file operations. |
opendir(path[, options]) | Opens a directory and returns an asynchronous iterable Dir object. |
readdir(path[, options]) | Reads the contents (files and subdirectories) of a directory. |
readFile(path[, options]) | Reads the entire contents of a file into memory. |
readlink(path[, options]) | Reads the value (target path) of a symbolic link. |
realpath(path[, options]) | Computes the canonicalized absolute pathname by resolving . and .. and symbolic links. |
rename(oldPath, newPath) | Renames (or moves) a file or directory from one path to another. |
rm(path[, options]) | Removes files and directories (supports recursive deletion). |
rmdir(path[, options]) | Removes an empty directory. |
stat(path[, options]) | Retrieves detailed information/statistics for a file or directory. |
statfs(path[, options]) | Retrieves general statistics about the file system hosting the specified path. |
symlink(target, path[, type]) | Creates a symbolic link pointing to the target. |
truncate(path[, len]) | Truncates a file to a specified length in bytes. |
unlink(path) | Deletes a file or symbolic link. |
utimes(path, atime, mtime) | Changes the file system access and modification timestamps of the object referenced by path. |
watch(filename[, options]) | Watches for changes on a file or directory and returns an AsyncIterator. |
writeFile(file, data[, options]) | Writes data to a file, completely replacing the file if it already exists. |
fs/promises module also exports a constants object containing common file system flags like fs.constants.F_OK