Working with the File System in Node.js
Node.js comes with a powerful built-in module for working with the file system: the fs module. Whether you need to read configuration files, write logs, process uploaded files, or build entire file-based databases, the fs module has you covered.
In this tutorial, you’ll learn how to read files, write files, work with directories, and handle common file system operations using both callbacks and promises.
Reading Files
The simplest way to read a file is with fs.readFile. Here’s how to read a text file:
const fs = require('fs');
fs.readFile('config.txt', 'utf8', (err, data) => {
if (err) {
console.error('Error reading file:', err);
return;
}
console.log('File contents:', data);
});
The callback receives the error first (Node.js convention), then the file contents. The second argument 'utf8' tells Node.js to decode the buffer as a UTF-8 string.
Reading Files with Promises
If you prefer async/await syntax, use fs/promises:
const fs = require('fs/promises');
async function readConfig() {
try {
const data = await fs.readFile('config.json', 'utf8');
const config = JSON.parse(data);
console.log('Config loaded:', config);
} catch (err) {
console.error('Error reading config:', err);
}
}
readConfig();
Synchronous Reading
For scripts where async doesn’t matter, use the synchronous versions:
const fs = require('fs');
const data = fs.readFileSync('config.txt', 'utf8');
console.log('Contents:', data);
Warning: Avoid synchronous file operations in production servers—they block the event loop and can cause performance issues.
Writing Files
Writing files works similarly with fs.writeFile:
const fs = require('fs/promises');
async function saveData() {
const data = { name: 'Alice', age: 30 };
await fs.writeFile('user.json', JSON.stringify(data, null, 2));
console.log('Data saved!');
}
saveData();
By default, writeFile overwrites the file. To append instead:
const fs = require('fs/promises');
async function appendToLog(message) {
const timestamp = new Date().toISOString();
await fs.appendFile('app.log', `[${timestamp}] ${message}\n`);
}
appendToLog('Application started');
Working with Directories
Create directories with fs.mkdir:
const fs = require('fs/promises');
async function setupProject() {
await fs.mkdir('src/utils', { recursive: true });
await fs.mkdir('src/components', { recursive: true });
console.log('Directories created!');
}
setupProject();
The recursive: true option prevents errors if the directory already exists.
List directory contents with fs.readdir:
const fs = require('fs/promises');
async function listFiles(dir) {
const files = await fs.readdir(dir);
console.log('Files:', files);
}
listFiles('./src');
Checking File Stats
Get file metadata with fs.stat:
const fs = require('fs/promises');
async function fileInfo(filepath) {
const stats = await fs.stat(filepath);
console.log('Is file:', stats.isFile());
console.log('Is directory:', stats.isDirectory());
console.log('Size:', stats.size, 'bytes');
console.log('Created:', stats.birthtime);
console.log('Modified:', stats.mtime);
}
fileInfo('config.json');
Deleting Files and Directories
Remove files with fs.unlink:
const fs = require('fs/promises');
async function cleanup() {
await fs.unlink('temp.txt');
console.log('File deleted');
}
cleanup();
Remove directories with fs.rmdir:
const fs = require('fs/promises');
async function removeDir(dir) {
await fs.rmdir(dir, { recursive: true });
console.log('Directory removed');
}
removeDir('old-folder');
Working with Paths
Always use the path module for cross-platform path handling:
const path = require('path');
const filePath = path.join(__dirname, 'config', 'settings.json');
console.log('Full path:', filePath);
const filename = path.basename('/home/user/documents/report.pdf');
console.log('Filename:', filename); // 'report.pdf'
const ext = path.extname('image.png');
console.log('Extension:', ext); // '.png'
Streams for Large Files
For large files, use streams to avoid loading everything into memory:
const fs = require('fs');
const readStream = fs.createReadStream('large-file.txt', 'utf8');
const writeStream = fs.createWriteStream('copy.txt');
readStream.on('data', (chunk) => {
writeStream.write(chunk);
});
readStream.on('end', () => {
console.log('File copied!');
});
readStream.on('error', (err) => {
console.error('Error:', err);
});
Or use pipeline for cleaner handling:
const fs = require('fs');
const { pipeline } = require('stream/promises');
async function copyFile(src, dest) {
const readStream = fs.createReadStream(src);
const writeStream = fs.createWriteStream(dest);
await pipeline(readStream, writeStream);
console.log('Copy complete!');
}
copyFile('large-file.txt', 'copy.txt');
Summary
Choosing The Right API
The file system module has a few layers, and the best choice depends on the kind of work you are doing. For scripts that run once and exit, synchronous calls can be fine because they keep the code short and direct. For servers, background jobs, and anything that handles concurrent requests, the promise-based API is a better default because it lets the event loop keep moving while disk work is in progress. That difference matters most when several users are hitting the same process at the same time.
Streams matter when data is too large to fit comfortably in memory. Instead of loading a whole file and then copying it, a stream lets Node.js move chunks through the pipeline as they arrive. That keeps memory use predictable and gives you a natural place to react to partial progress. It also fits well with transforms such as compression, encryption, and line-by-line processing. If you are unsure which API to start with, choose fs/promises first, then move to streams when the file size or throughput makes the simpler approach awkward.
Error Handling And Safety
Real file systems are messy. Paths can be wrong, files can disappear between checks, permissions can change, and directories can be locked by another process. Good code treats those cases as normal rather than exceptional. That usually means wrapping reads and writes in try and catch, checking for existence by attempting the operation, and deciding what should happen when the file is already gone. A cleanup step that deletes a temp file should be okay if the file is missing, while a configuration load should fail loudly if the file is not there.
It also helps to keep writes intentional. If a file matters, write to a temporary path first and then move it into place once the write succeeds. That pattern lowers the chance of leaving a half-written file behind if the process stops in the middle. For logs, append operations are often enough, but for structured data it is usually better to rewrite the whole file in a known format. When you combine careful error handling with the promise API, the code stays short without becoming fragile.
Path Hygiene
Cross-platform path handling deserves the same care as file reads and writes. String concatenation can work on your laptop and fail on another operating system because separators differ. The path module avoids that problem and also makes it easier to reason about folder boundaries. Use path.join() when building a path from several parts, and use path.resolve() when you need an absolute path from a relative one. Those habits reduce bugs that only appear after deployment.
The same idea applies to user input. If a path comes from a request, normalize it and check that it stays inside the directory you expect. A file server that accepts raw path strings can accidentally expose more than intended if it does not guard against traversal. Keeping the path logic in one place makes the rest of the code easier to trust, and it gives you a single spot to test when a path-related bug appears.
You’ve learned the fundamentals of working with Node.js file system:
- Read files with
fs.readFileorfs/promises - Write files with
fs.writeFileand append withfs.appendFile - Create directories with
fs.mkdir(userecursive: true) - List contents with
fs.readdir - Get metadata with
fs.stat - Delete with
fs.unlinkandfs.rmdir - Use streams for large files to avoid memory issues
The fs/promises API is recommended for modern Node.js applications—it works seamlessly with async/await and is easier to reason about.
Practical File Workflows
Most real file system tasks are combinations of the basics you have already seen. A script may read a file, transform the contents, and write a new version. A server may create a temp file, stream data into it, and then move it into place once the write finishes. A cleanup job may walk a directory tree and delete files that are no longer needed. Once you start thinking in workflows instead of single calls, the module becomes easier to use in production code.
That is also why it helps to keep the file system boundary small. One module can own reading, another can own writes, and a third can handle paths or cleanup. When the responsibilities are clear, the rest of the app does not have to remember every detail of how disk access works. The code becomes easier to change because each part has a clear job and a clear error path.
In the next tutorial, you’ll learn how to build an HTTP server with Node.js.