File System
Three flavours of the fs module
Section titled “Three flavours of the fs module”Node ships the same filesystem operations in three distinct APIs:
| Style | Import | When to use |
|---|---|---|
| Callback (async) | import fs from 'node:fs' | Legacy code, Node streams |
| Synchronous | import fs from 'node:fs' | CLI scripts, startup-time config |
| Promise (async) | import fs from 'node:fs/promises' | All new server code |
Reading a file
Section titled “Reading a file”Callback style
Section titled “Callback style”import fs from 'node:fs';
fs.writeFileSync('./hello.txt', 'Hello from Node.js!');
fs.readFile('./hello.txt', 'utf8', function(err, data) { if (err) throw err; console.log(data); fs.unlinkSync('./hello.txt');});Promise style (recommended)
Section titled “Promise style (recommended)”Needs the Node.js runtime — open in StackBlitz to run.
Writing a file
Section titled “Writing a file”writeFile creates the file if it does not exist and replaces the contents if it does. Use appendFile to add to an existing file.
Needs the Node.js runtime — open in StackBlitz to run.
Checking existence and stats
Section titled “Checking existence and stats”Needs the Node.js runtime — open in StackBlitz to run.
fs/promises vs sync — blocking the event loop
Section titled “fs/promises vs sync — blocking the event loop”readFileSync does not just “read a file slower” — it runs to completion before anything else in the process can run, including a timer that was already scheduled with a 0 ms delay. readFile (promise-based) hands the read off and returns immediately, letting the rest of the synchronous code — and the event loop — keep moving while the read happens in the background:
Output (verified on Node 24)
sync read length: 5000000
timer fired before readFileSync returned: false
next line ran before the read settledreadFile vs streaming a large file — memory
Section titled “readFile vs streaming a large file — memory”readFile() (sync or promise) allocates one buffer holding the entire file. For a 50 MB file that means one 50 MB allocation, held in memory until you’re done with it. A stream instead delivers the file in bounded chunks — by default no more than highWaterMark (64 KiB) at a time — so memory use stays roughly constant regardless of file size:
Output (verified on Node 24)
readFile() loaded bytes in one buffer: 52428800
stream delivered bytes across 800 chunks
largest single chunk (bytes): 65536
total bytes match: trueFor a 50 MB file, readFile needs one contiguous 50 MB allocation; the stream never holds more than 64 KiB at once — the difference that matters when the file could be gigabytes, or when hundreds of requests are reading files concurrently.
Globbing and cleanup in modern Node
Section titled “Globbing and cleanup in modern Node”Two newer APIs retire old dependencies. fs.glob (stable in Node 22+) replaces the glob package, and file handles support await using (Node 24+) so they close themselves:
import { writeFileSync, unlinkSync } from 'node:fs';import { glob, open } from 'node:fs/promises';
writeFileSync('./config.json', JSON.stringify({ port: 3000 })); // demo fixture
// Find files without the `glob` packagefor await (const file of glob('src/**/*.test.js')) { console.log(file);}
// The handle is closed when the block exits — even if it throws{ await using fh = await open('./config.json'); const text = await fh.readFile('utf8'); console.log(JSON.parse(text).port);}
unlinkSync('./config.json');Synchronous reads — startup only
Section titled “Synchronous reads — startup only”import { readFileSync, writeFileSync } from 'node:fs';
writeFileSync('./config.json', JSON.stringify({ port: 3000 })); // demo fixture
// Acceptable at startup — process has not yet started accepting requestsconst config = JSON.parse(readFileSync('./config.json', 'utf8'));console.log(config.port);Never call readFileSync inside a request handler, route callback, or any function invoked after the server starts.
Mental Model
Section titled “Mental Model”flowchart TB Call["fs call"] --> Sync["readFileSync / writeFileSync"] Call --> Async["readFile / writeFile (fs/promises)"] Sync -->|"runs to completion, no one else can run"| Blocks["blocks the event loop — every other request waits"] Async -->|"handed to the threadpool, returns a pending Promise"| Free["event loop keeps handling other work"] Blocks -->|"acceptable"| Startup["CLI scripts / before server.listen()"] Free -->|"required"| Server["server request handlers"]
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
fs sync (readFileSync) | Simplest code — no callback, no await, no threadpool round-trip | Blocks the entire process for the duration of the call — unacceptable once a server is accepting requests |
fs/promises (readFile) | Never blocks the event loop; composes with async/await | One extra threadpool hop per call — slightly higher latency than sync for a single call in a non-concurrent script |
readFile() whole-file | Simplest possible code — one buffer, no event wiring | Memory use scales with file size; a 1 GB file needs a 1 GB buffer |
createReadStream() | Constant memory regardless of file size; can start processing before the file finishes reading | More code — you handle 'data'/'end'/'error' (or use pipeline, covered in the streams module) |
Common Misconceptions
Section titled “Common Misconceptions”- “readFileSync is just a slower readFile” — fact: the difference isn’t speed, it’s that
readFileSyncblocks the entire process — no timer, no other request, no I/O callback can run until it returns. - “A quick readFileSync is fine because it only takes a few milliseconds” — fact: those milliseconds are stolen from every concurrent request the server is handling at that moment, not just the one that triggered the read.
- “readFile() and a stream use about the same memory since they read the same file” — fact:
readFile()holds the whole file as one buffer; a stream holds at most onehighWaterMarkchunk (64 KiB by default) at a time, independent of total file size.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a route handler or any function called after
server.listen()usereadFileSync,writeFileSync, orexistsSync? Any of these blocks every concurrent request for the duration of the call. - Does code load a potentially large or user-controlled file with
readFile()into a single buffer, instead of streaming it, when the file will just be forwarded or processed chunk-by-chunk anyway? - Does a file handle opened with
fs.open()/fsPromises.open()get closed on every exit path, including thrown errors — or wouldawait using(Node 24+) make that automatic?
🐛 Spot the bug
import { readFileSync } from 'node:fs';import http from 'node:http';
var server = http.createServer(function(req, res) {// reads the config file from disk on every single requestvar config = JSON.parse(readFileSync('./config.json', 'utf8'));res.end(JSON.stringify({ port: config.port }));});
server.listen(3000);Show the answer
readFileSync runs inside the request handler, so every incoming request blocks the entire event loop for the duration of the disk read — no other request can be accepted or answered while it runs, even ones that have nothing to do with this route. The fix is to read the config once at startup (before server.listen(), where a sync call is acceptable) and reuse the parsed object from a variable in every request, or use fs/promises’ readFile with a small in-memory cache if the file can change at runtime.