Node.js fs and path: Reading and Writing Files
A practical guide to file I/O in Node — fs/promises vs callback vs sync, path.join for safe paths, and how to find the current file with __dirname or import.meta.url.
What you'll learn
- ✓How to read and write files with fs/promises
- ✓The three fs APIs — callback, sync, and promise — and when each is appropriate
- ✓Why path.join beats string concatenation
- ✓How to find the current file path in CommonJS and ES Modules
- ✓How to list, create, and delete directories
Prerequisites
- •Node installed locally — see Install Node.js and Run Your First Script
- •Comfort with async/await — see JavaScript async/await
Reading and writing files is one of the things Node does well. The built-in fs and path modules cover everything from logging a quick line to streaming gigabytes of data. This post sticks to the parts you will use every day.
Three APIs, one module
fs is unusual in that it ships in three flavours:
- Callback-based — the original API.
fs.readFile(path, cb). - Synchronous — blocks the event loop.
fs.readFileSync(path). - Promise-based — modern, awaitable.
import { readFile } from 'node:fs/promises'.
For new code, default to fs/promises. The other two have their uses, but they are not where to start.
Read a file
import { readFile } from 'node:fs/promises';
const text = await readFile('notes.txt', 'utf-8');
console.log(text);
A few details worth knowing:
- The second argument,
'utf-8', tells fs to decode bytes as a string. Without it, you get a rawBuffer— useful for binary files like images, awkward for text. - The path is resolved relative to the current working directory, not the script’s location. We will fix that below.
- If the file does not exist,
readFilerejects with an error whosecodeis'ENOENT'. Catch it:
try {
const text = await readFile('notes.txt', 'utf-8');
console.log(text);
} catch (err) {
if (err.code === 'ENOENT') {
console.log('No notes file yet.');
} else {
throw err;
}
}
Write a file
import { writeFile } from 'node:fs/promises';
await writeFile('notes.txt', 'Hello, file system!\n', 'utf-8');
console.log('written');
writeFile overwrites the entire file. To append instead, use appendFile:
import { appendFile } from 'node:fs/promises';
await appendFile('log.txt', `[${new Date().toISOString()}] event\n`);
Both create the file if it does not exist.
Read and write JSON
A common pattern: read a JSON file, mutate it, write it back.
import { readFile, writeFile } from 'node:fs/promises';
const raw = await readFile('todos.json', 'utf-8');
const todos = JSON.parse(raw);
todos.push({ id: todos.length + 1, text: 'Write a blog post', done: false });
await writeFile('todos.json', JSON.stringify(todos, null, 2));
JSON.stringify(value, null, 2) produces pretty-printed JSON with two-space indentation — much nicer in version control than a one-line blob.
The callback API
You will see the callback API in older code and in some libraries:
import { readFile } from 'node:fs';
readFile('notes.txt', 'utf-8', (err, data) => {
if (err) return console.error(err);
console.log(data);
});
It works, but it nests quickly and does not compose with async/await. Stick with fs/promises unless you have a reason.
The sync API
The sync API blocks the event loop until the operation completes:
import { readFileSync } from 'node:fs';
const text = readFileSync('config.json', 'utf-8');
const config = JSON.parse(text);
Why does this exist? Two legitimate uses:
- Startup configuration. Reading a config file before your server boots. Blocking is fine — nothing else is happening yet.
- CLI tools. A short-lived script does not care about the event loop.
Never use sync APIs inside a request handler. A single blocking call there will freeze every other request on the server.
Try it yourself. Create a file script.js that reads package.json, parses it, and logs the project’s name and version. Use fs/promises and top-level await. Then try the same with readFileSync and notice the code is shorter — and decide which feels right for a small script vs a long-running server.
The path module
Building file paths by hand with + is fragile:
const file = dir + '/' + name + '.txt'; // breaks on Windows
const file = dir + name + '.txt'; // forgets the slash
path.join handles separators correctly across platforms:
import { join } from 'node:path';
const file = join(dir, name + '.txt');
// macOS/Linux: /home/me/notes/today.txt
// Windows: C:\Users\me\notes\today.txt
A few of the most useful functions:
import { join, resolve, dirname, basename, extname } from 'node:path';
join('a', 'b', 'c.txt'); // 'a/b/c.txt'
resolve('a', 'b'); // '/current/working/dir/a/b' (absolute)
dirname('/x/y/file.txt'); // '/x/y'
basename('/x/y/file.txt'); // 'file.txt'
basename('/x/y/file.txt', '.txt'); // 'file'
extname('/x/y/file.txt'); // '.txt'
resolve is join plus “make absolute relative to the current working directory.” Use it whenever you need an absolute path.
Where am I? __dirname and friends
The biggest gotcha with fs: paths are relative to the current working directory (where you ran node), not to the script. If a user runs node /home/me/app/script.js from /tmp, the path './data.txt' looks in /tmp, not in /home/me/app/.
To find the directory the script itself lives in, you need a different anchor.
In CommonJS, two magic variables are always available:
// CommonJS
console.log(__dirname); // /home/me/app
console.log(__filename); // /home/me/app/script.js
In ES Modules, those do not exist. Use import.meta:
// ESM, modern Node
console.log(import.meta.dirname); // /home/me/app
console.log(import.meta.filename); // /home/me/app/script.js
For older Node versions that lack import.meta.dirname, build it from import.meta.url:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import.meta.url is a file:// URL string. fileURLToPath converts it back to a plain OS path.
Putting it together
A script that reads data/config.json from next to itself, regardless of where you run it:
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
const here = import.meta.dirname;
const configPath = join(here, 'data', 'config.json');
const raw = await readFile(configPath, 'utf-8');
const config = JSON.parse(raw);
console.log(`Loaded ${Object.keys(config).length} config keys`);
Now node script.js, node /home/me/app/script.js, and cd /tmp && node /home/me/app/script.js all find the file.
Directories
Create a directory, recursively if needed:
import { mkdir } from 'node:fs/promises';
await mkdir('logs/2026/06', { recursive: true });
Without recursive: true, mkdir fails if any parent does not exist.
List the contents of a directory:
import { readdir } from 'node:fs/promises';
const names = await readdir('.');
console.log(names);
// [ 'index.js', 'package.json', 'README.md' ]
Need file metadata too?
const entries = await readdir('.', { withFileTypes: true });
for (const entry of entries) {
console.log(entry.name, entry.isDirectory() ? 'DIR' : 'FILE');
}
Delete a file:
import { unlink } from 'node:fs/promises';
await unlink('temp.txt');
Delete a directory and its contents:
import { rm } from 'node:fs/promises';
await rm('build', { recursive: true, force: true });
force: true suppresses the error if the directory does not exist — convenient in cleanup scripts.
Check if a file exists
There is no good “exists” function. The idiomatic pattern is to try the operation and handle the failure:
import { readFile } from 'node:fs/promises';
try {
const text = await readFile('maybe.txt', 'utf-8');
// use it
} catch (err) {
if (err.code !== 'ENOENT') throw err;
// file did not exist
}
This avoids a race condition: a file that existed when you checked might be deleted before you read it.
If you really need a check, use fs.access:
import { access, constants } from 'node:fs/promises';
try {
await access('maybe.txt', constants.R_OK);
console.log('readable');
} catch {
console.log('not readable');
}
Try it yourself. Write a script count-lines.js that takes a file path as the first command-line argument (process.argv[2]), reads the file, and prints the number of lines. Use fs/promises, handle the missing-file case with a helpful message, and resolve the path with path.resolve so relative paths work.
Watching files
For development tooling, you often want to react to file changes:
import { watch } from 'node:fs/promises';
const watcher = watch('src', { recursive: true });
for await (const event of watcher) {
console.log(event.eventType, event.filename);
}
This streams change events as an async iterable. It is how tools like build watchers, dev servers, and live-reload hooks know when to do work.
Streams for big files
For multi-gigabyte files, readFile loads the whole thing into memory and falls over. Use streams instead:
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
await pipeline(
createReadStream('huge.log'),
createGzip(),
createWriteStream('huge.log.gz')
);
Streams move chunks through a pipeline with bounded memory use. They are a topic of their own; for typical files, readFile is fine.
Recap
You now know:
- Default to
fs/promisesfor file I/O readFile,writeFile,appendFilecover most cases — pass'utf-8'for text- The sync API is fine for startup and CLI scripts, never inside a request handler
path.joinandpath.resolvebuild paths that work on every OS- Use
import.meta.dirname(ESM) or__dirname(CJS) to anchor paths to your script mkdir({ recursive: true }),readdir, andrmcover directory operations- For files too large to fit in memory, reach for streams
Next steps
Files on disk are great, but the real reason most people learn Node is to build HTTP services. The next post puts a small Express API in front of an in-memory store.
Next: Build Your First REST API with Express
Questions or feedback? Email codeloomdevv@gmail.com.
Related articles
- Node.js Install Node.js and Run Your First Script
A practical guide to installing Node.js with nvm on macOS, Linux, and Windows — checking versions, running .js files, and using the REPL for quick experiments.
- Node.js Node.js Modules: CommonJS vs ES Modules
A practical comparison of CommonJS and ES Modules in Node — require vs import, module.exports vs export, the type field in package.json, and the interop traps to avoid.
- Node.js npm and package.json Explained
A practical tour of npm and package.json — npm init, dependencies vs devDependencies, scripts, the lockfile, semver basics, and the difference between npm install and npx.
- Node.js What Is Node.js? JavaScript Outside the Browser
A clear introduction to Node.js — the V8 engine, the event loop, non-blocking I/O, the npm ecosystem, and when Node is the right runtime for your project.