AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 08 — Working with Files — The fs and path Modules

08 — Working with Files — The fs and path Modules

August 13, 20267 min read
Download as Markdown

"Just read the file" was my file-system model, and it skipped the two modules doing the real work. The model that finally stuck: the fs module does the work, the path module does the math, and the difference between __dirname and process.cwd() is where the file lives versus where I'm standing. Once those three clicked, file code stopped surprising me [1].

The framing that landed for me is the layers — core modules first, then the packages that fill their gaps.

The fs module: three flavors of every operation

The fs module is the built-in interface to the file system, and its defining trait is that every operation comes in three forms — synchronous, callback, and promise [2]. They do the same work; they differ in how they return the result.

import { readFileSync } from 'fs';                       // synchronous — blocks the event loop
const sync = readFileSync('/etc/hosts', 'utf8');

import { readFile } from 'fs'; // callback — Node classic
readFile('/etc/hosts', 'utf8', (err, data) => { /* ... */ });

import { readFile as readFileP } from 'fs/promises'; // promise — modern, awaitable
const data = await readFileP('/etc/hosts', 'utf8');

The synchronous forms (*Sync) block the main thread until the disk responds. In a server, that stalls every other connection, so they are reserved for startup-time work (reading a config before the server boots) or throwaway scripts. The callback form is the legacy async API — error-first callbacks, the same pattern as the rest of classic Node. The promise form, in fs/promises, is what I reach for in new code: it composes with async/await and gives clean try/catch error handling.

The discipline I keep: never use a *Sync call inside a request handler. Inside a server, every file operation is the promise form; inside a one-off CLI, synchronous is fine because there is no event loop to stall.

The path module: cross-platform path math

Paths are a string-handling problem that looks trivial until you've been bitten by it. path exists because joining path segments with + and / breaks the moment the code runs on Windows, where the separator is \. The path module does the separator-aware joining, normalizing, and resolving for me [3].

import path from 'path';

path.join('users', 'ave', 'notes.txt');
// 'users/ave/notes.txt' on Linux/macOS, 'users\\ave\\notes.txt' on Windows

path.resolve('users', 'ave'); // absolute path from cwd
path.extname('photo.JPG'); // '.JPG'
path.basename('/a/b/c.txt'); // 'c.txt'
path.dirname('/a/b/c.txt'); // '/a/b'

The rule: never concatenate paths with string operators. path.join exists precisely so that I do not write dir + '/' + file and ship a bug that only manifests on a different OS.

__dirname, __filename, and process.cwd()

The distinction I had to nail down is three things that all sound like "the current directory":

  • __dirname — the absolute path to the directory containing the file currently executing. Stays constant regardless of where I ran node from [4].
  • __filename — the absolute path to the file currently executing, including its name [5].
  • process.cwd() — the current working directory of the process: the directory I was in when I typed node script.js [6].
# from /home/ave, running a script at /app/src/index.js
cd /home/ave
node /app/src/index.js
// inside /app/src/index.js
console.log(__dirname); // '/app/src' — where the file lives
console.log(__filename); // '/app/src/index.js'
console.log(process.cwd()); // '/home/ave' — where I ran it from
the file's location (fixed) /app/src/ index.js __dirname = /app/src __filename = /app/src/index.js stays the same no matter where node was launched where you ran it (varies) $ cd /home/ave $ node /app/src/index.js /home/ave process.cwd() = /home/ave changes if you run node from a different directory

The bug this prevents: a script that reads a config relative to process.cwd() works when I run it from the project root and silently breaks when I run it from elsewhere. Reading relative to __dirname makes the script location-independent. For ESM, where __dirname is not injected, the equivalent is built from import.meta.url (covered in the modules notes).

glob and globby: finding files by pattern

When I need "all JavaScript files in any subdirectory," iterating fs.readdir recursively is tedious. glob matches files by shell-style wildcard patterns (**/*.js), and globby is its modern successor — promise-based, supports multiple patterns at once, and is what I reach for in build scripts and test runners [7][8].

import { globby } from 'globby';

const files = await globby(['**/*.js', '!node_modules']);
// every .js file except those under node_modules

The negation prefix (!) is the part I use most — exclude node_modules, exclude dist, get exactly the files I care about.

fs-extra, chokidar: filling the gaps

Two packages round out the everyday file toolkit. fs-extra adds the operations fs does not have out of the box — copy, remove (recursive delete), ensureDir (mkdir -p), readJson/writeJson — and adds promise support to the whole surface [9]. It is a near-drop-in replacement for fs that I reach for whenever the built-in API would require me to hand-roll a recursive operation.

chokidar is the file-watcher that underlies almost every dev tool — Webpack, Vite, Nodemon all use it. It wraps Node's native fs.watch/fs.watchFile (which are inconsistent across platforms) in a reliable, cross-platform API that emits add/change/unlink events [10].

import chokidar from 'chokidar';

chokidar.watch('./src').on('change', (path) => {
console.log(`${path} changed — rebuilding`);
});

That snippet is, in essence, what every "hot reload on save" feature does.

How I use this

The model I keep is the two modules plus the distinction. A few habits fall out. I use fs/promises for all in-request file work, never the *Sync forms. I join every path through path.join — no string concatenation, ever. I resolve config and asset paths relative to __dirname (or its ESM equivalent) so scripts are location-independent, and I reserve process.cwd() for inputs the user controls from the shell. For finding files by pattern I reach for globby; for recursive operations and JSON, fs-extra; for watching, chokidar. The mental separation — fs does the work, path does the math, and the two "current directory" globals mean different things — is what keeps file code boring and correct.

References

[1] DigitalOcean, "How To Work with Files using the fs Module in Node.js." [Online]. Available: https://www.digitalocean.com/community/tutorials/how-to-work-with-files-using-the-fs-module-in-node-js

[2] OpenJS Foundation, "File system," Node.js API Docs. [Online]. Available: https://nodejs.org/api/fs.html

[3] OpenJS Foundation, "Node.js file paths," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/manipulating-files/nodejs-file-paths

[4] OpenJS Foundation, "__dirname," Node.js API Docs. [Online]. Available: https://nodejs.org/docs/latest/api/modules.html#__dirname

[5] OpenJS Foundation, "__filename," Node.js API Docs. [Online]. Available: https://nodejs.org/docs/latest/api/modules.html#__filename

[6] OpenJS Foundation, "process.cwd()," Node.js API Docs. [Online]. Available: https://nodejs.org/api/process.html#processcwd

[7] "globby," GitHub. [Online]. Available: https://github.com/sindresorhus/globby

[8] "glob," npm. [Online]. Available: https://www.npmjs.com/package/glob

[9] "fs-extra," npm. [Online]. Available: https://www.npmjs.com/package/fs-extra

[10] "chokidar," npm. [Online]. Available: https://www.npmjs.com/package/chokidar

Knowledge check · Question 1 of 5

Inside a running server, which fs form should you use for a file read?

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!