In the previous lesson we used exactly two functions of fs: readFile and writeFile. With them Escena Viva already reads its catalog from disk, and that settled the debt we had been carrying since Module 1. But a single loose file is the simplest case in the file system.
Real applications work with directory trees: you need to know whether something exists and what it is, how much space it takes and when it was modified; to create folders that do not exist yet, list what is inside, delete the old stuff, move files around and, sometimes, read one specific chunk of a huge file without loading it whole. This lesson walks through that second half of fs and ends by building something Escena Viva already needs: a report store that organizes outputs by month, saves the day's occupancy report, lists what is there with its size and date, and automatically purges anything older than a certain age.
Contents
statand theStatsobjectstatversuslstat: symbolic links- Walking directories:
readdirandwithFileTypes - Creating and deleting trees:
mkdir,rm,rename,copyFile fs.constants, permissions andaccess- File descriptors and the
FileHandleAPI - Watching for changes:
watchversuswatchFile - The Escena Viva report store
stat and the Stats object
stat and the Stats objectstat answers the question "what does the operating system know about this path?". It returns a Stats object with the file's metadata: not its content, but its record card.
const fs = require('node:fs/promises');
const info = await fs.stat('data/events.json');
console.log(info.isFile()); // true
console.log(info.isDirectory()); // false
console.log(info.size); // 1893 (bytes)
console.log(info.mtime); // 2026-08-11T09:14:22.417Z (Date object)| Field or method | Type | What it is |
|---|---|---|
isFile() |
boolean | It is a regular file |
isDirectory() |
boolean | It is a directory |
isSymbolicLink() |
boolean | It is a symbolic link (only meaningful with lstat) |
size |
number | Size in bytes. On a directory it is not the sum of its contents |
mtime / mtimeMs |
Date / number |
Last modification of the content. The one you will use almost always |
ctime |
Date |
Last change of metadata (permissions, owner). It is not "creation" |
birthtime / atime |
Date |
Creation / last access. Neither is reliable on every system |
mode |
number | Permissions and type, in bits |
Two classic confusions: ctime does not mean creation time, but the change time of the metadata — renaming or changing permissions updates it without touching the content — and size on a directory is not the weight of what it contains, but that of the directory entry itself. To know how much a folder takes you have to walk it and add up.
Like any fs operation, stat fails with ENOENT if the path does not exist. That makes it the usual — and correct — way of answering "does this exist and what is it?" in a single call: wrap it in a try/catch that returns null on ENOENT and propagates any other code, exactly as we did in the previous lesson with readIfExists.
stat versus lstat: symbolic links
stat versus lstat: symbolic linksA symbolic link is a file that points to another path. The difference between the two functions is what they do when they run into one:
| Function | On meeting a symbolic link |
|---|---|
stat |
Follows it and returns the target's data |
lstat |
Does not follow it and returns the link's own data |
const target = await fs.stat('reports/latest.json'); // follows the link
console.log(target.isFile(), target.isSymbolicLink()); // true false
const link = await fs.lstat('reports/latest.json'); // does not follow it
console.log(link.isFile(), link.isSymbolicLink()); // false trueThe practical consequence matters: isSymbolicLink() always returns false with stat, because by then you have already jumped to the target; and if the link points to something deleted — a broken link — stat fails with ENOENT while lstat works and describes the link for you. Rule: use stat when you care about the content (the normal case) and lstat when you are walking or deleting a tree, so you do not follow links by accident and end up outside the directory you thought you were processing.
- Walking directories:
readdir and withFileTypes
readdir and withFileTypesconst names = await fs.readdir('reports');
console.log(names);
// [ '2026-07', '2026-08', 'sales-by-session.json' ]By default readdir returns names only, with no path and no hint about what each thing is. The natural impulse is to call stat on each one — for (const name of await fs.readdir(dir)) { const info = await fs.stat(${dir}/${name}); ... } — and with 5 entries it makes no difference; with 50,000 it means 50,000 extra system calls.
The withFileTypes option solves it: the operating system already knows the type of every entry while listing the directory, so Node hands it to you for free in the shape of Dirent objects.
// EFFICIENT: a single call, the type comes included.
const entries = await fs.readdir('reports', { withFileTypes: true });
for (const entry of entries) {
// entry.name is the name; entry.parentPath, the directory holding it.
if (entry.isDirectory()) console.log(`folder: ${entry.name}`);
else if (entry.isFile()) console.log(`file: ${entry.name}`);
}A Dirent offers the same predicates as Stats (isFile, isDirectory, isSymbolicLink) but carries no size or mtime: if you need size or date, then you do need stat. The rule is to filter by type first with Dirent and call stat only on what you truly care about. readdir also accepts recursive: true to walk the whole tree in one go, and it guarantees no ordering: if you need the reports sorted by date, sort them yourself.
- Creating and deleting trees:
mkdir, rm, rename, copyFile
mkdir, rm, rename, copyFile// Creates the whole tree. With recursive, it does NOT fail if it already exists.
await fs.mkdir('reports/2026-08', { recursive: true });
// Deletes a whole tree. force avoids the ENOENT if it is already gone.
await fs.rm('reports/2026-01', { recursive: true, force: true });
// Move or rename (atomic within the same file system).
await fs.rename('reports/draft.json', 'reports/2026-08/occupancy.json');
// Copy without overwriting the target if it already exists.
await fs.copyFile('data/events.json', 'data/events.backup.json', fs.constants.COPYFILE_EXCL);| Operation | Key options | Behavior to remember |
|---|---|---|
mkdir |
recursive: true |
Creates the missing parents and does not fail with EEXIST. Without it, it does |
rm |
recursive, force |
recursive for directories with content; force ignores that it is missing |
rename |
— | Atomic within the same file system; fails with EXDEV across different disks |
copyFile |
COPYFILE_EXCL |
By default it overwrites the target with no warning |
cp |
recursive: true |
Copies whole trees; the equivalent of cp -r |
Three details that save debugging sessions. First: mkdir with recursive is idempotent, so calling it before writing is the clean way of guaranteeing the destination, with no prior checks and no race conditions. Second: rm without recursive on a directory fails with ERR_FS_EISDIR; and rm with force and recursive asks nothing at all, so an rm(computedPath, {recursive: true, force: true}) with a badly computed path deletes whatever you put in front of it — always validate the path first. Third: rename fails with EXDEV if source and target live on different disks or volumes; there you have to copy and delete.
fs.constants, permissions and access
fs.constants, permissions and accessfs.constants gathers the numeric values the operating system expects. The ones you use daily are these:
| Constant | What for |
|---|---|
F_OK |
Does the path exist? |
R_OK / W_OK |
Can I read it? Can I write it? |
X_OK |
Can I execute it (or enter the directory)? |
COPYFILE_EXCL |
copyFile fails if the target exists |
O_RDONLY, O_WRONLY, O_CREAT, O_APPEND |
Low-level opening modes for open |
const fs = require('node:fs/promises');
const { constants } = require('node:fs');
try {
await fs.access('reports', constants.W_OK);
} catch {
console.error('[reports] the directory is not writable by this process');
}access returns nothing: it succeeds or it throws. And here it is worth remembering the previous lesson: using it as a step before an operation reintroduces the TOCTOU race condition. Its legitimate use is diagnostic, at startup, to give a clear message ("I cannot write into reports/") instead of a cryptic EACCES twenty minutes later.
Permissions are changed with chmod in octal notation: await fs.chmod(file, 0o600) leaves a file readable and writable only by its owner, which is what personal buyer data deserves (we will come back to it in Module 11). On Windows the permission model is different and chmod only affects the read-only bit.
- File descriptors and the
FileHandle API
FileHandle APIreadFile opens, reads whole and closes. When you need more control — reading only the first bytes, writing at a specific position, doing many operations on the same file without reopening it — you work with a file descriptor: an identifier the operating system hands you on opening, representing that particular open file.
In node:fs/promises, open() returns a FileHandle object wrapping that descriptor:
// Reads only the first bytes of a file, without loading it whole.
async function readHeader(file, byteCount) {
const handle = await fs.open(file, 'r');
try {
const target = Buffer.alloc(byteCount);
// read(buffer, offsetInBuffer, length, positionInFile)
const { bytesRead } = await handle.read(target, 0, byteCount, 0);
return target.subarray(0, bytesRead);
} finally {
// ALWAYS. Even on error, even with a return inside the try.
await handle.close();
}
}The try/finally is not optional. Every open descriptor occupies an entry in an operating system table with a per-process limit (usually a few thousand); if a function opens files and does not close them, every call leaks a descriptor and, after a few hours in production, the process starts failing with EMFILE: too many open files in operations that have nothing to do with it. And notice where the close goes: in the finally, not at the end of the try, because there any error from the read would take the close down with it. It is the same reasoning you will apply to database connections in Module 7.
FileHandle method |
What it does |
|---|---|
read(buffer, offset, length, position) |
Reads bytes into a Buffer from a position |
write(buffer | string, ...) |
Writes at a specific position |
readFile() / writeFile() / stat() |
Like the global ones, but on the already open file |
truncate(n) |
Trims the file to n bytes |
sync() |
Forces the system cache to flush to the physical disk |
close() |
Releases the descriptor |
sync() is the piece that completes the atomic write of the previous lesson: writing the temporary file, sync() and only then rename guarantees the content is physically on disk before the name change.
- Watching for changes:
watch versus watchFile
watch versus watchFileReacting to changes in a file sounds trivial and it is not. Node offers two mechanisms of opposite character:
fs.watch |
fs.watchFile |
|
|---|---|---|
| How it works | Operating system notifications (inotify, FSEvents, ReadDirectoryChangesW) | Polling: stat every N milliseconds |
| Cost | Very low | Constant, even when nothing changes |
| Latency | Immediate | Up to a full interval (5 s by default) |
| Reliability | Varies across platforms | Predictable everywhere |
| Recursive directories | recursive: true (not on every system) |
No |
fs.watch has a well-deserved bad reputation: it can emit two events for a single save (many editors write a temporary file and rename it), the eventType field ('rename' or 'change') does not always mean what it seems, the file name arrives as null on some platforms, and on networks or mounted volumes sometimes nothing arrives at all.
const { watch } = require('node:fs');
// Reloads the catalog when the seed changes, with debouncing.
let timer = null;
const watcher = watch('data/events.json', (eventType) => {
clearTimeout(timer); // groups the burst into a single reload
timer = setTimeout(() => {
console.error(`[catalog] ${eventType}: reloading seed`);
getCatalog({ reload: true }).catch((e) => console.error(e.message));
}, 200);
});
process.on('SIGINT', () => watcher.close());The debounce pattern with setTimeout is not decoration: without it, a single save triggers two or three reloads. And notice the watcher.close(): a watcher keeps the process alive, so you have to close it for the program to be able to finish. In serious watching projects, the honest recommendation is to use a library such as chokidar, which exists precisely to smooth out all these differences.
- The Escena Viva report store
Let's put it all together in a real module. src/reports/occupancy.js already knows how to compute summarizeByVenue since Module 2; what is missing is where to store those reports, how to list them and how to keep them from growing without limit.
// src/reports/report-store.js
// Saves, lists and purges the reports generated by Escena Viva.
// Layout: reports/YYYY-MM/occupancy-YYYY-MM-DD.json
const fs = require('node:fs/promises');
const BASE_DIR = 'reports';
// 2026-08-11T09:14:22.417Z -> { month: '2026-08', day: '2026-08-11' }
function partitionDate(date = new Date()) {
const day = date.toISOString().slice(0, 10);
return { month: day.slice(0, 7), day };
}
// Saves the day's report. If one already exists, it fails unless we overwrite.
async function saveReport(content, { date = new Date(), overwrite = false } = {}) {
const { month, day } = partitionDate(date);
const directory = `${BASE_DIR}/${month}`;
const file = `${directory}/occupancy-${day}.json`;
// Idempotent: creates the tree if missing and does not complain if it is there.
await fs.mkdir(directory, { recursive: true });
try {
await fs.writeFile(file, JSON.stringify(content, null, 2), {
encoding: 'utf8',
flag: overwrite ? 'w' : 'wx' // wx: fails if it already exists
});
} catch (error) {
if (error.code === 'EEXIST') {
const failure = new Error(`The report for ${day} already exists. Use overwrite to replace it.`);
failure.appCode = 'DUPLICATE_REPORT';
throw failure;
}
throw error;
}
return file;
}
// Names of the existing monthly folders. [] if there are none.
async function monthlyFolders() {
try {
const entries = await fs.readdir(BASE_DIR, { withFileTypes: true });
// We filter by type with Dirent: not a single stat so far.
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
} catch (error) {
if (error.code === 'ENOENT') return []; // none has been generated yet
throw error;
}
}
// Lists every report with its size and its modification date.
async function listReports() {
const reports = [];
for (const month of await monthlyFolders()) {
const directory = `${BASE_DIR}/${month}`;
const files = await fs.readdir(directory, { withFileTypes: true });
for (const file of files) {
if (!file.isFile() || !file.name.endsWith('.json')) continue;
const path = `${directory}/${file.name}`;
const info = await fs.stat(path); // stat only on what interests us
reports.push({
month,
file: file.name,
path,
sizeBytes: info.size,
modified: info.mtime.toISOString()
});
}
}
// readdir guarantees no ordering: we impose it ourselves.
return reports.sort((a, b) => a.path.localeCompare(b.path));
}
// Deletes the monthly folders older than the last N months.
async function purgeReports(monthsToKeep = 6, { dryRun = true } = {}) {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - monthsToKeep);
const cutoffMonth = cutoff.toISOString().slice(0, 7);
const purged = [];
for (const month of await monthlyFolders()) {
// Comparing 'YYYY-MM' as text works: the format is sortable.
if (month >= cutoffMonth) continue;
const path = `${BASE_DIR}/${month}`;
if (!dryRun) await fs.rm(path, { recursive: true, force: true });
purged.push(path);
}
return purged;
}
module.exports = { saveReport, listReports, purgeReports, BASE_DIR };Three design decisions deserve comment. The monthly partitioning (reports/2026-08/) avoids the classic problem of dumping a hundred thousand files into a single directory, where any readdir becomes slow, and it also turns the purge into an rm of a whole folder instead of a thousand separate deletions. The flag: 'wx' by default makes regenerating an already saved day's report an explicit error rather than a silent overwrite: if you really want it, you ask for it. And purgeReports runs a dry run by default, because a function that deletes recursively must demand that you confirm your intent, not the other way around. Notice as well that the paths are composed with template strings: it works on Linux and macOS, but it is exactly the mistake the next lesson will fix with path.join.
Common Mistakes and Tips
- Calling
staton everyreaddirentry. UsewithFileTypesand savestatfor when you actually needsizeormtime. - Reading
ctimeas the creation date. It is the date of the last metadata change. For creation,birthtime— and only if your system keeps it. mkdirwithoutrecursivefails withEEXISTif the folder already exists and withENOENTif a parent is missing. Withrecursive: trueboth problems disappear.- Leaving descriptors open. Every
open()needs itsclose()in afinally. The late symptom isEMFILEsomewhere unrelated to the failure. copyFileoverwrites by default (passfs.constants.COPYFILE_EXCLif you do not want that), andreaddirguarantees no ordering: sort explicitly.- Tip: any function that deletes should have a dry-run mode and use it by default;
rmwithrecursiveandforceasks nobody for confirmation. And iffs.watchfires duplicate callbacks at you, do not go looking for the bug in your code: that is normal behavior, apply debouncing.
Exercises
Exercise 1: disk usage report
Write src/lab/measure-folder.js that takes a path from process.argv and walks the tree recursively, computing: total number of files, number of directories, total size in bytes and the five largest files. It must use readdir with withFileTypes, not follow symbolic links (lstat) and print the result with console.table on stdout.
Exercise 2: generating and purging reports
Write src/reports/generate.js that uses getCatalog() and summarizeByVenue() to build the day's report, saves it with saveReport(), prints the full listing with listReports() and runs purgeReports(6) in dry-run mode, stating what would be deleted. It accepts --overwrite and --purge to move from simulation to action.
Exercise 3: log file rotation
Write a function rotateLog(file, maxSizeBytes) that, if the file exceeds the given size, renames it to file.YYYY-MM-DDTHH-mm-ss and leaves the original empty, keeping at most the five most recent rotations and deleting the rest. It must work even if the file does not exist yet.
Solutions
Solution 1. The recursive walk with withFileTypes is the pattern you will see again and again:
async function walk(directory, totals) {
const entries = await fs.readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const child = `${directory}/${entry.name}`;
// isSymbolicLink() on a Dirent does not follow the link: that is what we want.
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
totals.directories += 1;
await walk(child, totals);
} else if (entry.isFile()) {
const info = await fs.lstat(child);
totals.files += 1;
totals.bytes += info.size;
totals.largest.push({ path: child, bytes: info.size });
}
}
return totals;
}When it finishes, largest.sort((a, b) => b.bytes - a.bytes).slice(0, 5) gives the five biggest. Skipping symbolic links is not theoretical fussiness: a link pointing to an ancestor turns the walk into an infinite loop.
Solution 2. What matters is the order of the operations and that the write coexists with the EEXIST:
const events = await getCatalog();
const summary = {
generatedAt: new Date().toISOString(),
capacity: events.reduce((t, e) => t + e.totalCapacity, 0),
sold: events.reduce((t, e) => t + e.ticketsSold, 0),
byVenue: summarizeByVenue(events)
};
try {
const file = await saveReport(summary, { overwrite });
console.error(`[reports] saved to ${file}`);
} catch (error) {
if (error.appCode !== 'DUPLICATE_REPORT') throw error;
console.error(`[reports] ${error.message}`);
}
console.table(await listReports());
const purged = await purgeReports(6, { dryRun: !purge });
console.error(purge
? `${purged.length} folders deleted.`
: `${purged.length} folders would be deleted: ${purged.join(', ')}`);Solution 3. Rotation combines stat, rename and a purge in reverse order:
async function rotateLog(file, maxSizeBytes, rotationsToKeep = 5) {
let info;
try {
info = await fs.stat(file);
} catch (error) {
if (error.code === 'ENOENT') return false; // nothing to rotate yet
throw error;
}
if (info.size < maxSizeBytes) return false;
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
await fs.rename(file, `${file}.${stamp}`);
await fs.writeFile(file, '', 'utf8'); // leaves the original empty
const directory = file.slice(0, file.lastIndexOf('/')) || '.';
const base = file.slice(file.lastIndexOf('/') + 1);
const rotations = (await fs.readdir(directory, { withFileTypes: true }))
.filter((e) => e.isFile() && e.name.startsWith(`${base}.`))
.map((e) => e.name)
.sort()
.reverse(); // the ISO stamp sorts by date
for (const extra of rotations.slice(rotationsToKeep)) {
await fs.rm(`${directory}/${extra}`, { force: true });
}
return true;
}Two details: the : and . of the ISO stamp are replaced because they are not valid in file names on Windows, and sorting the rotations alphabetically is the same as sorting them by date because the ISO format is designed for that. Slicing the path with lastIndexOf('/') is exactly what path.dirname and path.basename will do properly — and portably — in the next lesson.
Conclusion
You now know the part of fs that goes beyond reading and writing one file. You can query metadata with stat and read a Stats object without confusing ctime with the creation date; you tell stat from lstat and understand why isSymbolicLink() only makes sense with the latter; you walk directories with readdir and withFileTypes, avoiding the per-entry stat that multiplies system calls; you create and destroy trees with mkdir({recursive}) — idempotent, and therefore the clean way to guarantee a destination — and with rm({recursive, force}), which asks nobody anything; you move with rename knowing it fails with EXDEV across volumes; and you copy with copyFile remembering it overwrites unless COPYFILE_EXCL.
You have also seen file descriptors and the FileHandle API, with the rule that admits no exceptions: every open() carries its close() in a finally, or sooner or later the EMFILE arrives. And you know the two watchers, watch and watchFile, with their opposite characters and the need for debouncing. Escena Viva, for its part, has gained src/reports/report-store.js with saveReport, listReports and purgeReports: reports are organized by month, they are not overwritten by accident thanks to flag: 'wx', they are listed with their size and date, and they are purged in dry-run mode unless you confirm otherwise.
One loose end remains, and it is a big one: every path in this module is built by gluing strings with /. It works on your machine and breaks the moment somebody runs the project from another directory or on Windows. In Cross-Platform Paths with the path Module we will see why concatenating paths is a mistake, the exact difference between join and resolve, the gulf between process.cwd() and __dirname, how a ../../etc/passwd sneaks into a path built from user data, and we will centralize the project's whole path configuration in src/config/paths.js to refactor what we wrote in these two lessons.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
