Node.js File Metadata: Permissions, Symlinks, Sparse Files, and Special Paths
File metadata is the operating-system state attached to a path or an open file. Node exposes that state through APIs such as fs.stat(), fs.lstat(), fs.fstat(), and fs.chmod(): mode bits, ownership, timestamps, size, inode data, type information, symlink behavior, and the edge cases that appear when a path does not name an ordinary disk file. fs.stat() follows symlinks, fs.lstat() reports the link itself, and chmod() updates mode bits where the platform supports them.
File Permissions and Metadata
The Stats object is only a snapshot. It tells you what the filesystem reported at one moment, not what will still be true when the next operation runs. That difference is important because devices, FIFOs, sockets, symlinks, sparse files, /proc, and network filesystems can all report values that look strange if your mental model is limited to regular files on a local disk.
Examples use CommonJS with node: built-in specifiers unless a snippet uses top-level await and imports from node:fs/promises.
The most familiar part of that metadata is the permission string you see from ls -l, such as -rw-r--r--. The nine permission characters encode who can read, write, or execute the file. They sit between your process and every file operation it attempts, although permission mistakes often surface later as EACCES or EPERM from the operation that finally touches the path.
Permissions are only one slice of the same structure. The operating system also tracks size, timestamps, ownership, file type, inode number, and block allocation separately from the file's content. Node exposes that state through fs.stat() and related calls, and the rest of this chapter is about reading those fields without assuming every path behaves like a normal file.
The Unix Permission Model
On Unix-like systems, the basic permission model starts with nine bits arranged as three groups of three:
- Owner (user): the person who created the file
- Group: users belonging to the file's assigned group
- Others: everyone else
Each group gets a read bit, a write bit, and an execute bit. The model is small, but it appears everywhere: a file operation first resolves the path, then the kernel checks the process identity against these owner, group, and other permissions.
Those bits are usually written as octal digits. Each digit is the sum of 4 for read, 2 for write, and 1 for execute, so rwx is 7, rw- is 6, r-x is 5, and r-- is 4. Three digits make a complete permission set. In -rw-r--r--, the first character is the file type (- for regular, d for directory, l for symlink), and the remaining nine characters map directly to the three octal digits: rw- = 6, r-- = 4, r-- = 4. The result is 0o644.
const fs = require('node:fs');
const stats = fs.statSync('./package.json');
const perms = stats.mode & 0o777;
console.log(perms.toString(8)); // "644"The mode field is an integer that carries more than permissions. Its high bits encode the file type, such as regular file, directory, symlink, or device. The low nine bits encode the permissions. Masking with 0o777 strips away the type information and leaves only those permission bits.
If you stat a file and see a raw mode like 33188, that is 0o100644 in octal. The file-type bits are 0o100000, meaning "regular file", and the low permission bits are 0o644. You almost never need to decode the file type from mode manually; stats.isFile(), stats.isDirectory(), and the other type-checking methods do that more clearly.

Figure 4.13 — The mode value carries type information above the permission field. Masking with 0o777 keeps the low permission bits, while the Stats type-checking methods decode the file-type portion.
These combinations appear often:
0o644- owner reads/writes, everyone else reads. Default for most files.0o755- owner reads/writes/executes, everyone else reads/executes. Standard for executables and directories.0o600- owner reads/writes, nobody else has access. Private keys, credentials.0o777- everyone can do everything. Almost never appropriate.
For directories, the execute bit has a different meaning. It controls search and traversal: whether you can enter the directory, resolve names inside it, and cd into it. A directory with read but no execute lets you list filenames, but you cannot open entries inside. A directory with execute but no read lets you access a known name, but you cannot list what is there. That is why directories usually get 0o755.
This is easy to misread because nothing is being "executed." A directory with 0o744 gives everyone else read permission, but without execute they can only list filenames. They cannot traverse into the directory or open entries inside it. The execute bit controls directory lookup.
How Default Permissions Work
When your process creates a file, the requested mode is not necessarily the final mode. The operating system applies the process umask first. The umask is a process-level bitmask that turns off selected permission bits on newly created files.
A common service or shell umask is 0o022. The math:
0o666 (default for files)
& ~0o022 (invert the umask, then AND)
= 0o644 (rw-r--r--)With that mask, fs.writeFile() creates files with 0o644 by default because the umask removed write permission from group and others. Directories start from 0o777 instead of 0o666, so the same 0o022 umask produces 0o755.
You can override default permissions explicitly when creating files:
fs.writeFileSync('./secret.key', keyData, { mode: 0o600 });That requests a file only the owner can read or write. The word "requests" is deliberate: the explicit mode option changes the permissions passed to the creation call, and the umask still filters that request. If the umask is 0o077 and you specify mode: 0o644, the resulting permissions are 0o600. If you need to normalize an existing file or assert the final state, call fs.chmod() after creation and handle failure.
process.umask() reads or sets the process umask, but changing it affects every file the process creates afterward. There is no per-operation umask, so application code is usually better off setting mode explicitly for the file being created. Reading the umask with process.umask() also has a trap: the no-argument form is thread-unsafe. Internally, Node calls the C umask(0) to read the value, which temporarily sets the mask to 0, then immediately restores it. That small window can race with file creation on worker threads and produce files with unexpected permissions. This is why Node deprecated the no-argument form (DEP0139).
Changing Permissions
fs.chmodSync('./deploy.sh', 0o755);After this call, deploy.sh has rwxr-xr-x. The owner can read, write, and execute it, while everyone else can read and execute it. On Unix, that execute permission is what makes a script runnable; there is no .exe convention like Windows. You write the script, set the execute bit, and the kernel allows it to run.
The kernel still controls who can make that change. Only the file's owner, or root, can change permissions. If your process does not own the file, fs.chmod() fails with EPERM. This is a kernel restriction, not a rule implemented in JavaScript.
fs.chmod('./config.json', 0o644, (err) => {
if (err && err.code === 'EPERM') {
console.error('Cannot change permissions - not the owner');
} else if (err) {
throw err;
}
});When you already have an open file, fs.fchmod() applies the same change through the file descriptor instead of a path. The promise API exposes the same idea as fileHandle.chmod().
Changing permissions also updates the file's ctime (change time) while leaving mtime alone. You changed metadata, not content. Build tools that watch mtime to decide what to rebuild will not treat a permission-only change as a source change.
A common deployment script uses that difference in a narrow way: after extracting a tarball or cloning a repository, scan the script directory and ensure shell scripts have execute permission.
const path = require('node:path');
for (const entry of fs.readdirSync('./bin', { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith('.sh')) {
fs.chmodSync(path.join('bin', entry.name), 0o755);
}
}The same low bits count for private files. SSH refuses to use a private key if it is group-readable or world-readable, and an application can enforce a similar rule before loading sensitive material:
function checkKeyPermissions(keyPath) {
const stats = fs.statSync(keyPath);
const perms = stats.mode & 0o777;
if (perms & 0o077) {
throw new Error(`${keyPath} has permissions ${perms.toString(8)}, expected 0600`);
}
}The bitmask 0o077 checks whether group or others have any access at all. If any of those bits are set, the key file is too permissive.
File Ownership and fs.chown()
Permission bits only make sense together with ownership. Every file has a numeric owner (uid) and group (gid). When a process opens a file, the kernel checks identity in order: a uid match selects owner permissions, a gid match selects group permissions, and everything else falls through to the others permissions.
That ordering can surprise people. If you are the owner but the owner permissions are more restrictive than the group permissions, you still get the owner permissions. The kernel uses the first applicable class, not the most permissive one.
const stats = fs.statSync('./app.log');
console.log(`uid: ${stats.uid}, gid: ${stats.gid}`);On Unix, process.getuid() tells you the current process's user id. Mapping numeric ids to user names requires system calls, and Node exposes the current user through os.userInfo():
const os = require('node:os');
const info = os.userInfo();
console.log(`Running as: ${info.username} (uid=${info.uid})`);fs.chown() changes ownership, but on Unix the useful cases are usually privileged. Regular users cannot transfer file ownership, which prevents quota bypasses and impersonation. Some systems allow a user to change the group to another group they belong to, but changing the uid requires root.
fs.chownSync('./app.log', 33, 33); // www-data on Debian/UbuntuTreat that as a root-only deployment example, not application code to run casually. It is mainly useful in scripts running as root, inside Docker image builds, or in setup scripts that create files owned by a service account. A container build might create log directories as root and then chown them to the application user before dropping privileges.
When the path is a symlink, fs.lchown() targets the link itself rather than the file it points to. That is rarely needed, but it keeps the same stat()/lstat() difference visible at the ownership layer.
fs.access(), Effective UIDs, and the TOCTOU Problem
Because permissions are checked by the kernel at the moment of use, a separate preflight check is narrower than it first appears. fs.access() asks whether the calling process can read, write, or execute a file:
const { constants } = require('node:fs');
fs.accessSync('./config.json', constants.R_OK | constants.W_OK);If the check fails, it throws. If it succeeds, you still might not be able to open the file.
One reason is a quirk of Unix user IDs. access() checks permissions using the process's real user ID (UID) and group ID (GID). Actual file operations such as open() usually use the process's effective permission identity. On Linux, file permission checks use fsuid/fsgid, which normally track effective IDs. If a process is running with elevated privileges, such as setuid, or has dropped privileges temporarily, fs.access() and fs.open() can disagree.
The other reason is the TOCTOU (Time of Check, Time of Use) race. Between the access() check and the later open() call, permissions can change, another process can chmod the file, the directory can be renamed, or the file can be deleted.
For normal file operations, the better pattern is to try the operation and handle the error. Do not check and then act; act and handle failure. The Node docs explicitly recommend against using fs.access() before open(), readFile(), or writeFile(). Use it only when you need to report accessibility without actually reading or writing the file, such as in a health check or status display.
Windows ACLs
The Unix model is not the native model on every platform. Windows uses Access Control Lists (ACLs). Each file can carry entries specifying which users or groups receive which rights, and an ACL can express cases beyond owner/group/others, such as one user receiving read-only access while another user in the same group is denied access.
An ACL contains multiple Access Control Entries (ACEs). Each ACE grants or denies specific rights, such as read, write, execute, or delete, to a specific user or group. Node does not expose a full Windows ACL management API through the ordinary Unix-style mode option.
Only a small subset of Unix-style modes maps to Windows behavior. In practice, mode changes mostly manipulate the read-only or writable state. Execute bits and owner/group/others differences are not implemented like Unix. Windows decides executability from file extensions (.exe, .bat, .cmd) and file associations rather than Unix execute bits.
fs.chown() exists on Windows for API compatibility, but it rarely does anything useful in ordinary application code. Windows ownership is tied to Security Identifiers (SIDs), not simple numeric uids. Cross-platform code should treat fs.chmod() as a basic read-only toggle on Windows and handle deeper permission policy through platform-specific tooling.
if (process.platform !== 'win32') {
fs.chmodSync('./script.sh', 0o755);
}Special Permission Bits
The Unix mode value also has three special bits in a fourth octal digit:
- Setuid (4): When set on an executable, it runs with the file owner's privileges regardless of who runs it. This is how
passwdchanges/etc/shadow(owned by root) when run by a regular user. - Setgid (2): On executables, runs with the file group's privileges. On directories, new files created inside inherit the directory's group rather than the creator's primary group. Useful for shared project directories.
- Sticky bit (1): On directories like
/tmp, prevents users from deleting files they don't own, even if they have write access to the directory.
You will see these in modes such as 0o1755 (sticky plus standard executable permissions) or 0o4755 (setuid). Application code rarely sets them; they are usually system administration concerns. Still, they explain why the first octal digit in stats.mode is sometimes not 0.
Metadata and the Stats Object
Permissions are part of a broader metadata snapshot. fs.stat() retrieves that metadata without reading file contents. On a warm local filesystem it is often cheap, but latency still depends on path lookup, kernel caches, the filesystem backend, and whether the path crosses a network or virtual filesystem.
const stats = fs.statSync('./data.json');
console.log(stats.size); // bytes
console.log(stats.mtimeMs); // last modification (ms)
console.log(stats.mode); // type + permissionsThe Stats object contains fields from the underlying filesystem metadata:
- size - Content length in bytes. This is the logical size. For regular files, it matches the byte count. For symlinks (via lstat), it's the length of the target path string. For directories, it is filesystem metadata, not a recursive content size. Many character devices and virtual files report
0because ordinary size is unsupported or not meaningful. - mode - File type (high bits) + permissions (low 9 bits). Decode with
stats.isFile()and friends, or mask with0o777for raw permissions. - uid, gid - Numeric owner and group. Map to usernames via
os.userInfo()or system tools. - nlink - Hard link count. How many directory entries point to this inode. Regular files usually start at 1. Traditional Unix filesystems often report 2 for an empty directory: the entry in the parent directory plus the
.entry inside the directory itself. Virtual, network, and FUSE filesystems can report differently. - ino - The inode number. Combined with
dev, this is the usual identity key for a file on POSIX-like filesystems within a running system. Network and virtual filesystems can have caveats. - dev - Device ID of the filesystem containing this file. Two files on different mounts have different
devvalues. - rdev - If this is a device file, the device ID it represents. For regular files, 0.
- blksize - Preferred I/O block size. Commonly 4096 on local Linux filesystems, but still a filesystem hint rather than a contract for your application.
- blocks - Allocated block count in the platform's
statrepresentation. On Linux this is conventionally reported in 512-byte units. Portable code should treat the unit as platform-defined.
Timestamps
The timestamp fields are where "metadata" becomes especially easy to misread. Four timestamps describe different events in a file's lifecycle, and choosing the wrong one can make a build tool miss changes or rebuild too much.
mtime (modification time) updates when file content changes. Any write to the file bumps mtime. This is the timestamp file tools use most often: build systems compare source mtime against output mtime, caches store mtime alongside cached results, and sync tools compare mtimes to detect changed content.
function needsRebuild(src, out) {
try {
const srcStat = fs.statSync(src);
const outStat = fs.statSync(out);
return srcStat.mtimeMs > outStat.mtimeMs;
} catch { return true; }
}The catch handles the common failure cases without turning the timestamp check into a full validation step. If the output does not exist, stat(out) throws ENOENT and the output needs rebuilding. If the source does not exist, stat(src) throws first, returning true and leaving the actual build step to surface the missing-file error.
atime (access time) tracks when the file was last read, at least in principle. Many Linux systems mount filesystems with relatime or noatime because updating atime on every read would turn every read into a metadata write. With relatime, the common default, atime updates only if the current atime is older than mtime or ctime, or if more than 24 hours have passed. With noatime, it never updates. Treat atime as a rough signal, not a precise access log.
ctime (change time) updates when metadata or content changes. Rename the file, chmod it, chown it, or write to it, and ctime changes. Node's timestamp APIs cannot set ctime directly; the kernel updates it when file status changes. It can reveal metadata changes that mtime alone hides, but it is not a tamper-proof audit record. A privileged attacker, clock control, filesystem restore, or low-level disk access can defeat it.
That name is often confused with "creation time." In POSIX terminology, the "c" means change, referring to inode status changes. Before Node v0.12, the confusion was worse because Node mapped Windows creation time to ctime, which encouraged developers to read ctime as creation time everywhere. Today, Node maps the Windows "ChangeTime" attribute to ctime for consistency with Unix and keeps true creation time in birthtime.
birthtime (creation time) records creation time when the filesystem exposes it. Treat it as optional. On some platforms and filesystems it is a true creation timestamp. On others, Node may return the Unix epoch or a value derived from ctime. If birthtime is the epoch or suspiciously equal to ctime, the filesystem may not expose creation time, although equality is only a weak hint.
Each timestamp exists in two forms: mtime (Date object) and mtimeMs (millisecond number). Use the Ms variants for comparisons - comparing numbers is faster and avoids Date coercion quirks:
if (stats.mtimeMs > cachedMtimeMs) {
console.log('file changed since last check');
}When you need to write timestamp metadata, fs.utimes() sets atime and mtime:
const now = new Date();
fs.utimesSync('./file.txt', now, now);This is the programmatic equivalent of touch. It is useful for forcing a rebuild by bumping mtime, preserving timestamps during file copies, or testing time-dependent code.
You can set atime and mtime within the range and precision supported by the OS and filesystem, assuming the process has permission. Past, present, and future timestamps are usually accepted for ordinary files. You cannot set ctime or birthtime through Node's timestamp APIs. The kernel owns ctime, and birthtime is not mutable through the ordinary fs.utimes() path.
A practical use of fs.utimes() in a backup tool:
fs.copyFileSync(src, dest);
const srcStats = fs.statSync(src);
fs.utimesSync(dest, srcStats.atime, srcStats.mtime);The copy now has atime and mtime restored as closely as Node and the filesystem can represent them. This does not preserve ctime or birthtime, and it may not be nanosecond-exact. Without this step, the copy would have the current time for both atime and mtime, which could make incremental backup tools treat it as new.
BigIntStats
The normal Stats object exposes timestamp fields as millisecond-unit numbers and Date objects. Their actual precision is still platform-specific. If you need the nanosecond-unit fields exposed by Node, pass { bigint: true } to stat():
const stats = fs.statSync('./file.txt', { bigint: true });
console.log(stats.mtimeNs); // 1678901234567890123nWith that option, all numeric fields become BigInts. Timestamps gain Ns variants such as mtimeNs, stored as nanosecond-unit BigInts. The unit is nanoseconds, but the real precision still depends on the OS and filesystem. Size, inode number, and block count are BigInts too. The tradeoff is that BigInt arithmetic is slower than regular number arithmetic, and BigInts cannot be mixed with regular numbers without explicit conversion. Use this only when you need the extra fields or integer range.
stat vs lstat vs fstat
The stat family keeps the same metadata idea but changes what object is being described:
fs.stat(path)- follows symlinks. If the path is a symlink, returns the target's metadata.fs.lstat(path)- does not follow symlinks. Returns the symlink's own metadata.fs.fstat(fd)- operates on an open file descriptor instead of a path.
The lstat() difference is important whenever the link itself is the object of interest. With stat(), a symlink to a 1 MB file reports size: 1048576. With lstat(), the symlink reports the byte length of the stored target path, such as 12 when the target string is 12 bytes. Only lstat() makes isSymbolicLink() return true.
fstat() is the descriptor-side version of the same idea. Once a file is open, it reports metadata for that open file rather than whatever the path might name later. That avoids another pathname lookup and avoids path-rebinding races between open() and a later path-based stat().
Type-Checking Methods
Because mode includes type bits, the Stats object provides methods for identifying what kind of file the metadata describes:
stats.isFile() // regular file
stats.isDirectory() // directory
stats.isSymbolicLink() // symlink (only true via lstat)
stats.isCharacterDevice() // e.g., /dev/null, /dev/urandom
stats.isBlockDevice() // e.g., /dev/sda
stats.isFIFO() // named pipe
stats.isSocket() // Unix domain socketThese methods decode the type bits from mode. Internally, they mask mode with S_IFMT (file type mask) and compare against constants such as S_IFREG (regular file), S_IFDIR (directory), and S_IFLNK (symlink). You could do this yourself with bitwise operations, but the methods are clearer and handle cross-platform differences.
Those checks become the first line of defense for file-processing code. Before calling readFile() on an arbitrary path, check isFile(). Before recursing into a directory entry, check isDirectory(). Before whole-file reads, make sure the path is not a character device that can produce infinite data.
A common pattern for directory traversal:
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile()) processFile(path.join(dir, entry.name));
if (entry.isDirectory()) recurse(path.join(dir, entry.name));
}The { withFileTypes: true } option returns Dirent objects instead of strings. Each Dirent has the same type-checking methods as Stats, but without requiring a separate stat() call. On Linux, this information usually comes from the readdir syscall itself through d_type, so there is no extra system call. Some filesystems report DT_UNKNOWN; for those entries, fall back to lstat().
Links and Inodes
The dev and ino fields lead to a deeper point: a path is a name, not the file's identity. On POSIX-like filesystems, that identity is the inode, a filesystem data structure containing metadata such as permissions, timestamps, size, block pointers, and a unique number within that filesystem. The inode stores everything about the file except its name and its content. Names live in directories as name-to-inode mappings. Content lives in data blocks that the inode points to.
That separation is what path lookup walks through. When you open /home/user/file.txt, the kernel resolves the path component by component: start at the root inode, find home in the root directory, load the home inode, find user, load that inode, then find file.txt and load its inode. The path is the lookup chain. The inode is the destination.
Because the name is separate from the inode, more than one name can point to the same file.
Hard Links
A hard link is another directory entry pointing to the same inode. Both names are equal; there is no original and copy. They share content, metadata, and permissions because they are the same file underneath.
fs.writeFileSync('a.txt', 'hello');
fs.linkSync('a.txt', 'b.txt');
console.log(fs.statSync('a.txt').ino === fs.statSync('b.txt').ino); // trueIf you modify the file through one name, the other name sees the change. After appendFileSync('b.txt', ' world'), readFileSync('a.txt') returns 'hello world'. There is one inode underneath and one set of data blocks; the two paths are just two directory entries referencing that inode.
The inode tracks the number of names pointing to it in nlink. A fresh file has nlink of 1. Create a hard link, and nlink becomes 2. fs.unlink() removes one directory entry and decrements nlink. The file's data is only freed when nlink reaches 0 and no open file descriptors still reference the inode.
The open-descriptor part is easy to miss. You can remove every name for a file, but if a process still has it open, the inode survives and the data stays on disk. The file has no name and no path, but the descriptor still works. When the last descriptor closes, the kernel finally reclaims the inode and data blocks. Unix programs use this for temporary storage: create a file, open it, immediately unlink it, and let the file exist only through the descriptor. When the process exits, the file disappears automatically.
That is why fs.unlink() is called unlink rather than delete. It removes a directory entry. The file may persist under other names or through open descriptors.
Hard links have filesystem constraints. They cannot cross filesystems because inode numbers are local to a filesystem, so a cross-device link fails with EXDEV. You also cannot hard link directories, because that would create cycles in the directory tree and normally fails with EPERM.
Those constraints still leave a useful pattern for backup tools. If a file has not changed between two snapshots, the tool can create a hard link in today's snapshot pointing to yesterday's copy. One set of data blocks serves both snapshots, so a rotation of mostly unchanged backups can use far less disk space than full copies.
Symbolic Links
A symbolic link takes the other approach. Instead of another name for the same inode, a symlink is a separate file whose content is a path string. When you access it, the filesystem reads that stored path and redirects the operation to the target. The symlink has its own inode, separate from the target's inode.
fs.symlinkSync('target.txt', 'link.txt');
console.log(fs.readlinkSync('link.txt')); // 'target.txt'fs.readlink() returns the raw path stored inside the symlink without following it. fs.readFile('link.txt') follows the symlink and reads the target. fs.lstat('link.txt') inspects the symlink's own metadata, while fs.stat('link.txt') follows through to the target's metadata.
Because a symlink stores a path rather than an inode number, it can cross filesystems and point to directories. It can also point to a nonexistent path, creating a dangling symlink that exists as a file but leads nowhere; following it throws ENOENT. Symlinks can point to other symlinks too, forming chains that the kernel resolves recursively up to a limit, usually 40 on Linux. Circular chains produce ELOOP.
fs.symlinkSync('b.txt', 'a.txt');
fs.symlinkSync('a.txt', 'b.txt'); // circular
fs.readFileSync('a.txt'); // throws ELOOPThe stored path can be relative or absolute. A relative symlink stores something like ../data/file.txt, resolved from the symlink's directory. Move the symlink elsewhere, and that relative path can break. An absolute symlink stores /home/user/data/file.txt, which is stable across moves on the same machine but less portable across machines.
That gives symlinks a different failure mode from hard links. Delete the target of a symlink, and the symlink becomes dangling. Delete one hard-link name, and the file may still exist under another name. Hard links share the inode and survive name removal; symlinks store a path and break when that path stops resolving.

Figure 4.14 — Hard links add directory entries to the same inode. A symbolic link is a separate filesystem object whose stored path is resolved later, so it can dangle or cross filesystems.
Most operations follow symlinks transparently: stat, readFile, writeFile, chmod, and chown. A few do not. lstat inspects the link itself, unlink removes the link rather than the target, readlink reads the stored path, and rename renames the link itself.
When you need a canonical target, fs.realpath() resolves all symlinks in a path and returns the final absolute path. That is useful for comparisons where two different symlinks may reach the same underlying file.
Deployment tools use this behavior for atomic version switching. A release can be unpacked into a new directory, exposed through a current -> releases/v2 symlink, and swapped by creating a temporary symlink and rename()-ing it over the old one. Processes see either v1 or v2, not a half-updated tree.
Detecting Same-File Relationships
Once paths can converge through hard links and symlinks, string comparison is not enough to decide whether two paths name the same file. For ordinary POSIX-like local filesystems, compare dev and ino:
function sameFile(p1, p2) {
const s1 = fs.statSync(p1);
const s2 = fs.statSync(p2);
return s1.dev === s2.dev && s1.ino === s2.ino;
}Both dev and ino must match. Inode numbers are only unique within a filesystem, so dev disambiguates across mount points. Two files on different filesystems can share an inode number by coincidence. If you need to compare symlink objects themselves instead of their targets, use lstat() rather than stat().
When Files Aren't Files
The same metadata APIs also reveal paths that are not ordinary files at all. Unix exposes many kernel interfaces through pathname-like filesystem entries, and a tool that accepts arbitrary paths can encounter objects that block forever, produce infinite data, or report sizes that do not describe readable content.
Device Files
Files in /dev represent kernel interfaces backed by driver code rather than disk storage. A few common examples show why file type checks count:
/dev/null discards all writes and returns EOF immediately on reads. fs.readFile('/dev/null') returns an empty buffer. Writes succeed instantly and the data goes nowhere. It is commonly used as a data sink in shell pipelines.
/dev/urandom produces an infinite stream of cryptographically random bytes from the kernel's CSPRNG (Cryptographically Secure Pseudo-Random Number Generator). The stream never ends. There is no EOF, so fs.readFile('/dev/urandom') can keep reading and allocating memory until the process exhausts memory or hits implementation limits. Tools that accept arbitrary paths should reject infinite character devices or read them with explicit byte limits.
You must use fixed-size reads:
import { open } from 'node:fs/promises';
const handle = await open('/dev/urandom', 'r');
const buf = Buffer.alloc(32);
try {
await handle.read(buf, 0, 32, null);
} finally {
await handle.close();
}For random bytes, prefer crypto.randomBytes(32). It handles the platform interaction internally and works cross-platform. It uses Node's OpenSSL-backed cryptographic random implementation, seeded from operating-system random sources on supported platforms. The exact OS call depends on the Node and OpenSSL build.
/dev/zero produces infinite zero bytes. Same hang potential as /dev/urandom with readFile(). Occasionally used for performance benchmarks or creating zero-filled files, but always with explicit byte limits.
Many character devices like these report stats.size of 0 and stats.isCharacterDevice() as true. Block devices such as /dev/sda and /dev/nvme0n1 represent raw disk storage. They are seekable and have finite size, but reading them returns raw disk sectors, often requires root, and is not what a file-processing tool usually expects.
Detect and reject:
if (stats.isCharacterDevice() || stats.isBlockDevice()) {
throw new Error(`Cannot process device file: ${filePath}`);
}The /proc Filesystem
/proc is a virtual filesystem on Linux. Its "files" do not exist on disk; the kernel generates their content when you read() them. /proc/cpuinfo exposes CPU information, /proc/meminfo shows memory statistics, and /proc/[pid]/status reveals per-process details.
The catch is that stat() reports size as 0 for many /proc files. The kernel does not store ordinary file content for these entries; it generates text at read time. Code that pre-allocates buffers based on stats.size therefore gets nothing:
const stats = fs.statSync('/proc/cpuinfo');
console.log(stats.size); // 0 - but the file has contentfs.readFile() handles this case because it reads in chunks until EOF instead of trusting the reported size. Low-level code using fs.read() with a buffer sized to stats.size would allocate 0 bytes and read nothing.
The generated nature of /proc also means the content is a snapshot. Read /proc/meminfo twice with a 100ms gap and you may get different numbers. These are not stored file bytes; the kernel runs the formatting path when the file is read.
/proc/self is a symlink to /proc/[your-pid]/, convenient for introspection. /proc/[pid]/cmdline gives you a process's command-line arguments (null-separated bytes). /proc/[pid]/fd/ is a directory of symlinks - each one points to the file that file descriptor number has open. Useful for debugging leaked fds.
Because /proc exists only on Linux, portable code has to guard this path. macOS uses sysctl for similar system information, and Windows has no equivalent in the filesystem namespace:
const path = require('node:path');
function readProcText(somePath) {
const resolved = path.resolve(somePath);
if (process.platform === 'linux' && resolved.startsWith('/proc/')) {
return fs.readFileSync(resolved, 'utf8');
}
}That guard is still only a classification check. Real trust splits need a policy for path resolution, symlinks, and which /proc entries are acceptable.
Named Pipes (FIFOs) and Unix Sockets
Named pipes are inter-process communication channels that appear as filesystem entries. When one process opens a named pipe for reading, it blocks until another process opens the pipe for writing, and the reverse is also true. If a file-processing tool treats a FIFO like a regular file, it can hang waiting for the other end to connect.
Unix domain sockets are local IPC endpoints used by Docker (/var/run/docker.sock), PostgreSQL, MySQL, and many system daemons. They show up in stat() results and look like files, but they are not readable with readFile() or createReadStream(). They are connection endpoints that speak specific protocols.
Check for both:
if (stats.isFIFO() || stats.isSocket()) {
throw new Error('Cannot process IPC endpoint');
}Sparse Files
Some regular files have their own metadata surprises. A sparse file has logical byte ranges with no allocated disk blocks. The filesystem records those missing ranges as holes and allocates real blocks only when non-hole data is written. Reads from a hole return zero bytes to the application.
const stats = fs.statSync('disk-image.qcow2');
const logicalSize = stats.size; // 50GB
const actualBytes = stats.blocks * 512; // 2GBOn Linux, that multiplication is the common way to estimate allocated bytes from st_blocks. The file reports 50 GB but uses about 2 GB on disk. The other 48 GB is holes: logical zeros that cost no storage.

Figure 4.15 — A sparse file can report a large logical size while allocating blocks only for written ranges. Copy strategies count because a naive byte-for-byte copy can materialize holes as real zero-filled storage.
Sparse files behave like regular files when you read them. If you read from a hole, you get zeros. The problem is copying. fs.readFileSync() followed by fs.writeFileSync() reads those zeros from holes and writes them as real data. The 2 GB-on-disk file can become a 50 GB copy because the destination has no holes and every zero byte is backed by storage.
For sparse-aware copies on Linux with GNU coreutils, use GNU cp:
const { spawnSync } = require('node:child_process');
const result = spawnSync('cp', ['--sparse=always', src, dest]);
if (result.error || result.status !== 0) {
throw result.error || new Error(result.stderr?.toString() || 'cp failed');
}The --sparse=always flag tells cp to detect runs of zeros and create holes in the destination instead of writing them. Passing arguments as an array keeps paths out of shell parsing.
Detect sparse files by comparing logical size to actual disk usage:
function isSparse(stats) {
return stats.blocks * 512 < stats.size;
}On Linux, stats.blocks follows the usual st_blocks convention of 512-byte units. POSIX does not define that unit, and some platforms or filesystems can differ. Treat blocks * 512 as a Linux or common-POSIX estimate, not a universal Node guarantee. If the allocated-byte estimate is less than stats.size, there are holes.
Sparse files are common with VM disk images such as QCOW2 and VMDK, database files where engines preallocate large files, and container overlay filesystems. If a tool copies or processes arbitrary files, decide whether preserving sparseness is required before choosing a copy strategy.
A Safe File Type Guard
The earlier type checks can be combined into a small guard for code that expects ordinary files:
async function ensureRegularFile(filePath) {
const stats = await fs.promises.stat(filePath);
if (stats.isFile()) return stats;
if (stats.isDirectory()) throw new Error('Is a directory');
if (stats.isCharacterDevice() || stats.isBlockDevice()) throw new Error('Device file');
if (stats.isFIFO()) throw new Error('Named pipe');
if (stats.isSocket()) throw new Error('Unix socket');
throw new Error('Unknown file type');
}Call this before file operations that expect regular files. It prevents unbounded reads on /dev/urandom, blocking on named pipes, directory errors, and the usual special-file surprises.
This is a classification guard, not a security control. It uses stat(), so symlinks are followed, and the path can change after the check. For hostile paths, open first and inspect the open descriptor with fstat() where possible.
The VFS Layer Behind stat()
When you call fs.stat('./file.txt'), the operation crosses runtime and operating-system handoffs: JavaScript, Node's native binding layer, libuv, and the kernel. The exact native path is platform-specific, but the split explains why some files report size 0, why metadata lookup is separate from content I/O, and why different filesystems can all expose the same Stats interface.
Node's fs.stat() calls into its C++ binding layer, which calls libuv's filesystem API. Callback and promise-based filesystem APIs return through libuv's filesystem machinery; Node documents these APIs as using libuv's thread pool, except for filesystem watchers. On Linux, current libuv may use newer metadata syscalls such as statx when available and fall back when needed. Those details can move between Node and libuv releases, so application code should depend on Node's Stats contract rather than a specific syscall sequence.
On Linux and other Unix-like kernels, the system call enters the kernel and hits the VFS (Virtual File System) layer. VFS is the kernel abstraction that provides a uniform interface to mounted filesystems. ext4, XFS, Btrfs, NFS, procfs, devtmpfs, and tmpfs all present common operations to code above VFS. The actual driver beneath that layer implements operations such as lookup, getattr, read, write, open, mkdir, and unlink.
When stat() reaches VFS, the kernel first resolves the path. The directory entry cache, or dcache, stores name-to-inode lookups in memory. If the relevant entries are cached, resolution can be a series of memory lookups with no disk I/O. If an entry is missing, VFS calls the filesystem's lookup operation, which reads directory metadata from disk, a network server, or another cache layer to find the inode.
Once VFS has the inode, it calls the filesystem's getattr operation. For a local disk filesystem, that usually means reading inode metadata from the inode cache or loading it from storage. For NFS, it can mean an RPC request to the remote server. For procfs, there is no ordinary disk file content to inspect.
This is the lower-level reason many /proc entries report size: 0 even though a read returns content. Procfs does not store ordinary file content and stable sizes the way disk files do. When VFS calls procfs's metadata path, procfs can return synthetic metadata with size = 0. The content is generated when you read the file: the kernel formats current kernel state into bytes and returns those bytes to your process.
Devtmpfs, which often backs /dev, is different but has a related edge. /dev/null and /dev/urandom are character devices backed by kernel driver functions. When you stat() them, the kernel can return inode metadata for the device node, but size is commonly 0 because the device has no stored content. Reads are handled by the driver's read operation.
The VFS abstraction is what makes fs.stat() look uniform across these backends. Whether the path names a local ext4 file, an NFS file, a procfs virtual entry, or a devtmpfs device node, the JavaScript object has the same field names. The behavioral differences surface in the values and during actual I/O: regular files have stable sizes, /proc files often report size 0 but yield content, and character devices may produce infinite data.
Because metadata lookup does not require reading file contents, content length is usually not the dominant cost on a warm local filesystem. The kernel may satisfy path and inode metadata from caches. A cold path, slow disk, network mount, FUSE filesystem, or virtual backend can make the same stat() call much slower. The careful claim is narrower: metadata lookup normally does not scale with file content length on local filesystems, but it still depends on the filesystem and cache state.
The return path reverses the layers. The kernel fills a platform stat structure, libuv stores the result in its filesystem request data, the event loop is notified that the operation completed, and Node constructs a JavaScript Stats object. By the time the callback runs or the promise resolves, platform metadata has been converted into fields such as stats.size, stats.mtimeMs, and stats.mode.
Working From Metadata
Treat metadata as a snapshot, not a promise about the next operation. Mask mode before interpreting permissions. Prefer operation-then-handle-error over preflight access() checks. Use lstat() for the link itself and fstat() when you already hold the open file. Reject non-regular files before whole-file reads, and treat timestamps and block counts as platform data. That discipline keeps the JavaScript code honest about where the state really lives.