---
title: "08 — Working with Files — The fs and path Modules"
uid: working-with-files
tags: ["path", "filesystem", "chokidar", "files", "nodejs", "glob", "roadmap:nodejs", "fs"]
excerpt: "fs does the work, path does the math, and the difference between __dirname and process.cwd() is where the file lives versus where you're standing."
date: 2026-08-13T03:27:56+0000
source: https://www.aveshina.my.id/en/blog/working-with-files
---

"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
```

```figure
<svg viewBox="0 0 740 220" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="__dirname versus process.cwd(). Left: the executing file /app/src/index.js, so __dirname is /app/src and __filename is /app/src/index.js — fixed by the file's location. Right: the shell where the user typed the command from /home/ave, so process.cwd() is /home/ave — reflects where node was launched, not where the file lives.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- file tree (left) -->
    <text x="160" y="24" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">the file's location (fixed)</text>
    <rect x="60" y="40" width="200" height="140" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="80" y="62" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b">/app/src/</text>
    <rect x="90" y="78" width="140" height="28" rx="4" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="160" y="96" font-size="10" font-family="ui-monospace, monospace" fill="#052e16" text-anchor="middle">index.js</text>
    <text x="160" y="128" font-size="10" font-weight="700" fill="#052e16" text-anchor="middle">__dirname = /app/src</text>
    <text x="160" y="146" font-size="10" font-weight="700" fill="#052e16" text-anchor="middle">__filename = /app/src/index.js</text>
    <text x="160" y="168" font-size="9" font-style="italic" fill="#475569" text-anchor="middle">stays the same no matter</text>
    <text x="160" y="180" font-size="9" font-style="italic" fill="#475569" text-anchor="middle">where node was launched</text>

    <!-- shell (right) -->
    <text x="560" y="24" font-size="11" font-weight="700" fill="#500724" text-anchor="middle">where you ran it (varies)</text>
    <rect x="460" y="40" width="200" height="140" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="480" y="62" font-size="10" font-family="ui-monospace, monospace" fill="#500724">$ cd /home/ave</text>
    <text x="480" y="80" font-size="10" font-family="ui-monospace, monospace" fill="#500724">$ node /app/src/index.js</text>
    <rect x="490" y="96" width="140" height="28" rx="4" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="560" y="114" font-size="10" font-family="ui-monospace, monospace" fill="#422006" text-anchor="middle">/home/ave</text>
    <text x="560" y="146" font-size="10" font-weight="700" fill="#422006" text-anchor="middle">process.cwd() = /home/ave</text>
    <text x="560" y="168" font-size="9" font-style="italic" fill="#475569" text-anchor="middle">changes if you run node</text>
    <text x="560" y="180" font-size="9" font-style="italic" fill="#475569" text-anchor="middle">from a different directory</text>
  </g>
</svg>
```

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](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](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](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](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](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](https://nodejs.org/api/process.html#processcwd)

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

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

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

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

```quiz
Q: Inside a running server, which fs form should you use for a file read?
- The synchronous *Sync form — it is simpler
- The promise form from fs/promises — it does not block the event loop
correct: 1
explain: Synchronous fs calls block the single main thread, stalling every other connection. In a server, always use the callback or promise form; reserve *Sync for startup or throwaway scripts.

Q: Why use path.join instead of string concatenation for paths?
- It is faster
- It handles the platform-specific separator (/ on Unix, \ on Windows) and normalization for you
correct: 1
explain: Hardcoding '/' breaks on Windows. path.join uses the correct separator for the platform and normalizes the result, so the same code works across operating systems.

Q: __dirname and process.cwd() differ in that…
- they are the same thing
- __dirname is where the executing file lives; process.cwd() is the directory the process was launched from
correct: 1
explain: __dirname is fixed by the file's location. process.cwd() reflects the shell's working directory when node was invoked. A script reading config relative to cwd will break when run from a different directory.

Q: You want to recompile a file whenever it changes on disk. Reach for…
- fs.watch (native)
- chokidar
correct: 1
explain: chokidar wraps the inconsistent native fs.watch/fs.watchFile APIs in a reliable cross-platform watcher. It is what Webpack, Vite, and Nodemon use under the hood.

Q: globby's `!` prefix in a pattern means…
- match hidden files
- exclude (negate) that pattern from the result set
correct: 1
explain: A leading ! negates the pattern, so ['**/*.js', '!node_modules/**'] matches all .js files except those inside node_modules.
```
