The fs module is Node.js's interface to the file system. It is one of the original core modules — present since Node.js's first release — and it is the module that makes Node.js capable of something browser JavaScript was never designed to do: reading from and writing to files on disk.
Almost everything a Node.js application does with files — reading configuration, serving static assets, writing logs, processing uploads, generating reports — goes through fs.
Importing the Module
// CommonJS
const fs = require('fs');
// ES Modules
import fs from 'fs';
The fs module ships with three parallel APIs for working with the file system, and choosing the right one matters:
| API style | Import | Behaviour |
|---|---|---|
| Callback-based | require('fs') | Original API — error-first callbacks |
| Promise-based | require('fs/promises') | Same operations, returns Promises |
| Synchronous | require('fs'), methods ending in Sync | Blocks the event loop until complete |
This topic covers all three, but the Promise-based API is the one you should reach for by default in modern Node.js code, used with async/await.
Reading Files
Asynchronous — Promise-based (Recommended)
const fs = require('fs/promises');
async function readConfig() {
try {
const data = await fs.readFile('config.json', 'utf8');
console.log(data);
} catch (err) {
console.error('Failed to read file:', err.message);
}
}
The second argument, 'utf8', is the encoding. Without it, readFile returns a raw Buffer — binary data — rather than a string. For text files, always specify an encoding. For binary files (images, PDFs, executables), omit it and work with the Buffer directly.
const buffer = await fs.readFile('image.png');
console.log(buffer); // <Buffer 89 50 4e 47 ...>
const text = await fs.readFile('notes.txt', 'utf8');
console.log(text); // the actual text content, as a string
Asynchronous — Callback-based
const fs = require('fs');
fs.readFile('config.json', 'utf8', (err, data) => {
if (err) {
console.error('Failed to read file:', err.message);
return;
}
console.log(data);
});
This is the original fs API, following the error-first convention. It remains widely used in older codebases and in situations where a callback-based interface is specifically required.
Synchronous
const fs = require('fs');
try {
const data = fs.readFileSync('config.json', 'utf8');
console.log(data);
} catch (err) {
console.error('Failed to read file:', err.message);
}
readFileSync blocks the event loop until the file is fully read. This is appropriate in a narrow set of situations — reading a configuration file once during application startup, before the server begins handling requests, or in short-lived command-line scripts where there is no concurrent work to protect. It should never be used inside a request handler or anywhere performance under concurrent load matters, because it blocks every other operation for the duration of the read.
Writing Files
const fs = require('fs/promises');
async function saveLog(message) {
try {
await fs.writeFile('app.log', message, 'utf8');
console.log('Log saved');
} catch (err) {
console.error('Failed to write file:', err.message);
}
}
writeFile overwrites the file entirely if it already exists. If you need to add to an existing file rather than replace it, use appendFile:
async function appendLog(message) {
await fs.appendFile('app.log', `${new Date().toISOString()} — ${message}\n`, 'utf8');
}
Writing JSON
A common pattern is serialising an object to JSON before writing it:
async function saveConfig(config) {
const json = JSON.stringify(config, null, 2);
await fs.writeFile('config.json', json, 'utf8');
}
The second and third arguments to JSON.stringify — null and 2 — control formatting. null means no replacer function is used, and 2 means the output is indented with two spaces, producing readable, formatted JSON rather than a single compressed line.
Checking If a File Exists
There is no dedicated exists function in the modern fs API — the original fs.exists was deprecated because of a race condition risk (the file's existence can change between the check and the subsequent operation). The recommended approach is to attempt the operation directly and handle the error if the file is not found:
async function readIfExists(filepath) {
try {
return await fs.readFile(filepath, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
return null; // file does not exist
}
throw err; // some other error — re-throw it
}
}
ENOENT ("error, no entry") is the standard Node.js error code for a missing file or directory. Checking err.code rather than the error message is the reliable way to distinguish a missing file from other kinds of failures, such as a permissions error (EACCES).
If you genuinely need to check for existence without reading the file — for example, before deciding which of two code paths to take — fs.access() is the correct tool:
async function fileExists(filepath) {
try {
await fs.access(filepath);
return true;
} catch {
return false;
}
}
Working with Directories
Creating a Directory
await fs.mkdir('uploads');
// Create nested directories in one call
await fs.mkdir('uploads/2026/june', { recursive: true });
Without { recursive: true }, mkdir fails if any parent directory in the path does not already exist. With it, Node.js creates every missing directory in the path automatically.
Reading a Directory's Contents
const files = await fs.readdir('uploads');
console.log(files); // ['report.pdf', 'image.png', 'notes.txt']
To get more detail than just filenames — whether each entry is a file or a directory — pass { withFileTypes: true }:
const entries = await fs.readdir('uploads', { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
console.log(`Directory: ${entry.name}`);
} else {
console.log(`File: ${entry.name}`);
}
}
Removing a Directory
// Empty directory only
await fs.rmdir('uploads/temp');
// Directory and everything inside it
await fs.rm('uploads/temp', { recursive: true, force: true });
fs.rm with { recursive: true, force: true } is the modern, general-purpose removal function — it works on files and directories, and force: true prevents an error if the path does not exist.
Getting File Information — fs.stat
fs.stat returns metadata about a file or directory — size, creation time, modification time, and type.
const stats = await fs.stat('config.json');
console.log(stats.size); // size in bytes
console.log(stats.isFile()); // true
console.log(stats.isDirectory()); // false
console.log(stats.mtime); // last modified date
console.log(stats.birthtime); // creation date
This is commonly used to validate file size before processing an upload, check when a file was last modified for caching purposes, or determine whether a path refers to a file or a directory before deciding how to handle it.
Copying, Moving, and Deleting Files
// Copy a file
await fs.copyFile('source.txt', 'destination.txt');
// Move or rename a file
await fs.rename('old-name.txt', 'new-name.txt');
// Delete a file
await fs.unlink('temporary.txt');
fs.rename is used for both renaming a file in place and moving it to a different directory — the distinction is just whether the destination path changes the directory or just the filename. Note that fs.rename does not work across different disk volumes or partitions on some systems; for cross-volume moves, copy the file and then delete the original.
Watching for File Changes
fs.watch monitors a file or directory and fires a callback whenever a change is detected:
const fs = require('fs');
fs.watch('config.json', (eventType, filename) => {
console.log(`${filename} changed: ${eventType}`);
});
This is the mechanism behind tools that automatically reload an application when source files change during development — nodemon, for example, uses file watching internally. fs.watch's behaviour varies slightly across operating systems and is generally considered less reliable than dedicated file-watching libraries like chokidar for production use cases, but it is useful to know it exists at the core module level.
Working with Streams Instead of Loading Entire Files
Every method covered so far — readFile, writeFile — loads the entire file into memory at once. This is fine for small files, but becomes a serious problem for large ones. Reading a 2GB video file with readFile attempts to load all 2GB into memory simultaneously.
For large files, fs.createReadStream and fs.createWriteStream process data in smaller chunks, without holding the entire file in memory at once:
const fs = require('fs');
const readStream = fs.createReadStream('large-video.mp4');
const writeStream = fs.createWriteStream('copy.mp4');
readStream.pipe(writeStream);
This reads and writes the file in manageable chunks, piped directly from source to destination. Streams are a significant enough topic that they are covered in full detail in their own dedicated topic later in this series — the key thing to know here is that fs.createReadStream and fs.createWriteStream exist as the streaming alternative to readFile and writeFile, and they are the correct choice whenever file size is large or unpredictable.
Common File System Error Codes
| Code | Meaning |
|---|---|
ENOENT | No such file or directory — the path does not exist |
EACCES | Permission denied |
EEXIST | File already exists (relevant for operations that should not overwrite) |
EISDIR | Expected a file but the path is a directory |
ENOTDIR | Expected a directory but the path is a file |
EMFILE | Too many open files — file descriptor limit reached |
Checking err.code rather than parsing err.message is the reliable way to handle specific failure scenarios in your code, since error messages can vary slightly across Node.js versions and operating systems while codes remain stable.
Summary
| Task | Method |
|---|---|
| Read a file | fs.readFile(path, encoding) |
| Write a file (overwrite) | fs.writeFile(path, data, encoding) |
| Append to a file | fs.appendFile(path, data, encoding) |
| Check if a file exists | fs.access(path) — catch the error if it does not |
| Create a directory | fs.mkdir(path, { recursive: true }) |
| List a directory's contents | fs.readdir(path) |
| Remove a file or directory | fs.rm(path, { recursive: true, force: true }) |
| Get file metadata | fs.stat(path) |
| Copy a file | fs.copyFile(source, destination) |
| Rename or move a file | fs.rename(oldPath, newPath) |
| Delete a file | fs.unlink(path) |
| Watch for changes | fs.watch(path, callback) |
| Stream a large file | fs.createReadStream(path) / fs.createWriteStream(path) |