How does the File System (fs) module work in Node.js?
The fs (File System) module is Node.js's built-in API for reading, writing, modifying, and monitoring files and directories on disk using synchronous, callback, or Promise-based methods.
Theory
TL;DR
- Think of
fslike a restaurant: you place an order (file operation), the kitchen staff (libuv threads) does the work, and the front desk (event loop) stays free to handle other customers - Three API styles exist: callbacks (legacy), sync (blocking), and
fs/promises(current standard) - Async
fsoperations are non-blocking because Node delegates disk I/O to libuv's thread pool, not the main thread - Use
fs/promisesfor server code,readFileSynconly in scripts, streams for files over ~50MB - Default libuv thread pool size is 4 (tunable via
UV_THREADPOOL_SIZE)
Quick example
const fs = require('fs/promises');
async function demo() {
await fs.writeFile('test.txt', 'Hello Node'); // non-blocking write
const data = await fs.readFile('test.txt', 'utf8'); // reads as string
console.log(data); // Hello Node
await fs.unlink('test.txt'); // cleanup
}
demo().catch(console.error);
// Event loop stays free while libuv handles disk I/OTwo things worth noting here. The 'utf8' encoding option matters: without it you get a raw Buffer, not a string. And the whole chain is non-blocking.
How it works internally
Node.js fs binds to OS-level syscalls (read(2) on Linux, ReadFile on Windows) through libuv. When you call fs.readFile, Node does not read the file itself. It hands the task to libuv's thread pool (4 threads by default) and immediately returns control to the event loop. When libuv finishes, it pushes a completion event into the queue. V8 picks it up and runs your callback or resolves your Promise.
Sync versions skip the thread pool entirely and call the OS directly from the main thread. That is why readFileSync freezes everything else until it finishes. For I/O-heavy apps you can scale the pool: UV_THREADPOOL_SIZE=16 node app.js. This comes up in senior interviews.
Three API styles
Callback-based (the original style, still common in legacy codebases):
const fs = require('fs');
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});Synchronous (blocks the event loop; fine only in scripts or CLI tools):
const data = fs.readFileSync('data.txt', 'utf8');
console.log(data);Promise-based (the current standard; use fs/promises):
const fs = require('fs/promises');
const data = await fs.readFile('data.txt', 'utf8');Performance difference between callbacks and promises is negligible in practice. Promises add microtask overhead, but error handling and chaining are much cleaner. I migrated a Node.js logging service from nested callbacks to fs/promises last year and the code shrank from 80 lines of nested handlers to 25 lines of async/await.
When to use which style
- CLI scripts or startup config loading:
readFileSyncis fine. No concurrency, blocking is acceptable. - Express or Fastify servers:
fs/promiseswithasync/await. Non-blocking keeps throughput high under load. - Files over ~50MB:
createReadStream. Loading a 10GB file withreadFilecauses an out-of-memory crash. - File watching:
fs.watchfor OS-level events (fast, low overhead),fs.watchFilefor polling (more reliable on network file systems). - Legacy codebases: callbacks are fine. No need to refactor unless you hit the pyramid problem.
Core operations at a glance
Read and write:
const fs = require('fs/promises');
const text = await fs.readFile('file.txt', 'utf8'); // read as string
await fs.writeFile('output.txt', 'Hello'); // create or overwrite
await fs.appendFile('server.log', `${new Date()} - req\n`); // append without overwriteDirectories:
await fs.mkdir('logs/prod', { recursive: true }); // creates nested dirs atomically (Node 10.12+)
const entries = await fs.readdir('src', { withFileTypes: true });
entries.forEach(e => console.log(e.name, e.isDirectory() ? 'dir' : 'file'));Stats, delete, rename:
const stats = await fs.stat('file.txt');
console.log(stats.size, stats.mtime); // bytes, last modified timestamp
await fs.unlink('old.txt'); // delete file
await fs.rm('old-folder', { recursive: true }); // delete folder
await fs.rename('old.txt', 'new.txt'); // rename or move
await fs.copyFile('source.txt', 'dest.txt'); // copyCommon mistakes
Using sync methods in a web server:
// Wrong - blocks ALL concurrent requests
app.get('/', (req, res) => {
const data = fs.readFileSync('large.json');
res.send(data);
});
// Right
app.get('/', async (req, res) => {
const data = await fs.readFile('large.json', 'utf8');
res.send(data);
});One blocked readFileSync call can drop a server from 1000 RPS to 10 RPS under load. The event loop serializes everything behind it.
Forgetting encoding on readFile:
const data = await fs.readFile('file.txt');
console.log(data); // <Buffer 48 65 6c 6c 6f> - not what you wanted
// Fix: always pass encoding for text files
const data = await fs.readFile('file.txt', 'utf8'); // returns stringThis is one of the most common fs questions on Stack Overflow. The default return type is Buffer, not a string.
Not awaiting promises in a loop:
// Wrong - forEach does not wait; writes fire in unpredictable order
files.forEach(async (file) => {
await fs.writeFile(file, 'data');
});
// Right
for (const file of files) {
await fs.writeFile(file, 'data');
}Callback pyramid (the classic smell in legacy code):
// Wrong
fs.readFile('a.txt', (err, data) => {
fs.writeFile('b.txt', data, (err) => {
fs.unlink('a.txt', (err) => { /* deeper and deeper */ });
});
});
// Fix: switch to fs/promises
const data = await fs.readFile('a.txt', 'utf8');
await fs.writeFile('b.txt', data);
await fs.unlink('a.txt');Ignoring race conditions with mkdir:
// Without { recursive: true }, two concurrent processes both check "dir exists?"
// and both try to create it → second one throws EEXIST and crashes
await fs.mkdir('logs', { recursive: true }); // atomic since Node 10.12, safeReal-world usage
- Express/Fastify:
fs.appendFilein request middleware for logging (same pattern Morgan uses internally) - Next.js:
fs.readdirSyncat build time ingetStaticPathsto list pages from the file system - Webpack:
fs.readFileSyncfor reading asset manifests and injecting content hashes into bundles - PM2:
fs.writeFileSyncto write PID files for process management in clusters - NestJS:
fs.readFilefor loading.envor config files before the app starts
For production file watching, most teams use chokidar (a wrapper around fs.watch) because fs.watch has known edge cases on macOS and network drives.
Follow-up questions
Q: What is the default libuv thread pool size for fs operations, and how do you change it?
A: 4 threads by default. Set UV_THREADPOOL_SIZE=16 node app.js (max 1024) to increase for I/O-heavy workloads. Relevant for file upload servers or batch processing jobs.
Q: What is the difference between fs.watch and fs.watchFile?
A: fs.watch uses OS-level file events (inotify on Linux, FSEvents on macOS): fast and low-overhead. fs.watchFile polls at intervals: slower, but more reliable on network file systems and some macOS edge cases.
Q: How do you stream a 10GB file through an Express response without crashing the process?
A: Pipe fs.createReadStream directly to res. Chunks go to the client as they are read, keeping memory usage flat. Calling readFile on a 10GB file loads the whole thing into memory and the process dies.
Q: In a PM2 cluster, how do you write to a shared log file without corrupting it?
A: fs.appendFile is atomic for small writes on most operating systems. For larger critical writes, use a locking library like proper-lockfile, or route all writes through a dedicated logger process. Concurrent writeFile calls to the same file will overwrite each other.
Q: Why does fs.stat sometimes seem to return symlink data instead of the target file's data?
A: It does not. fs.stat always follows symlinks and returns data for the target. fs.lstat is what returns metadata about the symlink itself. This trips up automated tests that check file sizes on symlinked paths.
Examples
Express server with non-blocking request logging
This shows how fs.appendFile keeps the server responsive while writing a log entry on every upload:
const express = require('express');
const fs = require('fs/promises');
const app = express();
app.post('/upload', async (req, res) => {
const logLine = `${new Date().toISOString()} - User uploaded file\n`;
await fs.appendFile('server.log', logLine, 'utf8');
// disk write runs in libuv thread; event loop handles other requests
res.send('Logged');
});
app.listen(3000);
// server.log: "2026-04-14T22:00:00.000Z - User uploaded file"The event loop never blocks. Other incoming requests are processed while libuv handles the disk write in the background.
Handling ENOENT and concurrent directory creation
A common trap in multi-process setups like PM2 clusters. Two worker processes both try to create the same directory at startup:
const fs = require('fs/promises');
const path = require('path');
async function safeMkdirWrite(dir, filename) {
try {
await fs.mkdir(dir, { recursive: true }); // atomic since Node 10.12
await fs.writeFile(path.join(dir, filename), 'data');
console.log('Written');
} catch (err) {
if (err.code === 'EEXIST') {
console.log('Dir already existed, continuing');
} else {
throw err; // unexpected error, re-throw
}
}
}
safeMkdirWrite('logs/prod', 'app.log');
// Output: Written
// Without { recursive: true }: concurrent calls throw ENOTDIRThe { recursive: true } flag is what makes this safe. Without it, the second concurrent call throws EEXIST and the worker crashes.
Streaming a large file to avoid memory issues
const fs = require('fs');
function streamFile(filePath, destination) {
const readStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const writeStream = fs.createWriteStream(destination);
readStream.pipe(writeStream);
readStream.on('error', (err) => console.error('Read error:', err.message));
writeStream.on('finish', () => console.log('Done streaming'));
}
streamFile('large-data.csv', 'output.csv');
// Memory stays flat regardless of file size
// fs.readFile on a 10GB file = out-of-memory crashThe key difference: readFile allocates memory for the entire file at once. createReadStream holds only the current chunk in memory, then discards it after piping.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.