Skip to main content

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 fs like 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 fs operations are non-blocking because Node delegates disk I/O to libuv's thread pool, not the main thread
  • Use fs/promises for server code, readFileSync only in scripts, streams for files over ~50MB
  • Default libuv thread pool size is 4 (tunable via UV_THREADPOOL_SIZE)

Quick example

js
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/O

Two 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):

js
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):

js
const data = fs.readFileSync('data.txt', 'utf8'); console.log(data);

Promise-based (the current standard; use fs/promises):

js
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: readFileSync is fine. No concurrency, blocking is acceptable.
  • Express or Fastify servers: fs/promises with async/await. Non-blocking keeps throughput high under load.
  • Files over ~50MB: createReadStream. Loading a 10GB file with readFile causes an out-of-memory crash.
  • File watching: fs.watch for OS-level events (fast, low overhead), fs.watchFile for 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:

js
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 overwrite

Directories:

js
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:

js
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'); // copy

Common mistakes

Using sync methods in a web server:

js
// 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:

js
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 string

This 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:

js
// 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):

js
// 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:

js
// 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, safe

Real-world usage

  • Express/Fastify: fs.appendFile in request middleware for logging (same pattern Morgan uses internally)
  • Next.js: fs.readdirSync at build time in getStaticPaths to list pages from the file system
  • Webpack: fs.readFileSync for reading asset manifests and injecting content hashes into bundles
  • PM2: fs.writeFileSync to write PID files for process management in clusters
  • NestJS: fs.readFile for loading .env or 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:

js
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:

js
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 ENOTDIR

The { 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

js
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 crash

The 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?