Skip to content
Codeloom
Node.js

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.

·8 min read · By Codeloom
Beginner 11 min read

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

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 raw Buffer — 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, readFile rejects with an error whose code is '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:

  1. Startup configuration. Reading a config file before your server boots. Blocking is fine — nothing else is happening yet.
  2. 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/promises for file I/O
  • readFile, writeFile, appendFile cover most cases — pass 'utf-8' for text
  • The sync API is fine for startup and CLI scripts, never inside a request handler
  • path.join and path.resolve build paths that work on every OS
  • Use import.meta.dirname (ESM) or __dirname (CJS) to anchor paths to your script
  • mkdir({ recursive: true }), readdir, and rm cover 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.