Suggest an editImprove this articleRefine the answer for “How does the File System (fs) module work in Node.js?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**The `fs` (File System) module** is Node.js's built-in API for reading, writing, and managing files on disk. Async operations run in libuv's thread pool, keeping the event loop free for other work. ```js const fs = require('fs/promises'); const data = await fs.readFile('file.txt', 'utf8'); // string, not Buffer ``` **Key point:** Always pass `'utf8'` to `readFile`, or you get a raw `Buffer` back, not a string.Shown above the full answer for quick recall.Answer (EN)Image**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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.