Skip to content

File System

Node ships the same filesystem operations in three distinct APIs:

StyleImportWhen to use
Callback (async)import fs from 'node:fs'Legacy code, Node streams
Synchronousimport fs from 'node:fs'CLI scripts, startup-time config
Promise (async)import fs from 'node:fs/promises'All new server code
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');
});
Node.js

Needs the Node.js runtime — open in StackBlitz to run.

writeFile creates the file if it does not exist and replaces the contents if it does. Use appendFile to add to an existing file.

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

Node.js

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:

Node.js
Output (verified on Node 24)
sync read length: 5000000
timer fired before readFileSync returned: false
next line ran before the read settled

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

Node.js
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: true

For 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.

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` package
for 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');
import { readFileSync, writeFileSync } from 'node:fs';
writeFileSync('./config.json', JSON.stringify({ port: 3000 })); // demo fixture
// Acceptable at startup — process has not yet started accepting requests
const 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.

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"]
sync fs calls run to completion and block everything else; async fs calls hand the work to the threadpool and return immediately
ChoiceBenefitCost
fs sync (readFileSync)Simplest code — no callback, no await, no threadpool round-tripBlocks 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/awaitOne extra threadpool hop per call — slightly higher latency than sync for a single call in a non-concurrent script
readFile() whole-fileSimplest possible code — one buffer, no event wiringMemory 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 readingMore code — you handle 'data'/'end'/'error' (or use pipeline, covered in the streams module)
  • “readFileSync is just a slower readFile” — fact: the difference isn’t speed, it’s that readFileSync blocks 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 one highWaterMark chunk (64 KiB by default) at a time, independent of total file size.
  • Does a route handler or any function called after server.listen() use readFileSync, writeFileSync, or existsSync? 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 would await 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 request
var 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.

Why should you avoid fs.readFileSync inside a server request handler?
What does fs.writeFile do if the target file already exists?
Which import gives you the promise-based fs API?
What does this print? var timerFired = false; setTimeout(() => { timerFired = true; }, 0); var data = readFileSync(path, "utf8"); console.log(timerFired);
A 200 MB file is read once with fs.readFile() and once with fs.createReadStream(). Which statement about memory use is correct?
What is wrong with calling readFileSync('./config.json') inside an HTTP request handler that runs on every request?