Node.js 16 🟢 Synchronous vs Asynchronous vs Promise-based File Operations
The node:fs module provides three ways to read a file. The synchronous form returns the contents directly and blocks the thread until the read completes. The callback form starts the read, returns immediately, and invokes a function when the data is ready. The promise form wraps the callback in a Promise and works with async/await. All three eventually do the same thing — read bytes from disk — but they differ in how the thread is used, how errors are handled, and what the calling code looks like. Choosing the right form is the difference between a server that serves thousands of requests and one that stalls on a single slow read.
The distinction matters because Node.js runs JavaScript on a single thread. A synchronous read blocks that thread, so no other JavaScript executes while the read is in progress. For a script that reads one configuration file at startup, that is fine. For a request handler that reads a file on every request, it is a denial of service waiting to happen. The asynchronous forms delegate the blocking work to the libuv thread pool, and the event loop continues to process other work until the read completes.
This chapter covers the three API forms, the blocking behavior of each, the error handling patterns, the performance characteristics, the rules for choosing between them, and the patterns that keep the event loop responsive.
Key point: Use the promise API (node:fs/promises) with async/await for all application code. Use the synchronous API only at startup and in scripts, where blocking does not matter. Use the callback API when integrating with legacy code or when the error-first convention is required. All three forms are equivalent in what they do; they differ in how they use the thread and how they handle errors.
Why three API forms exist
The blocking problem. The synchronous API is the simplest to read. readFileSync returns the contents, and the next line uses them. But it blocks the event loop until the read completes. If the file is on a slow disk, the entire process is idle but unavailable. For a server, that is unacceptable.
The callback problem. The callback API was the original asynchronous form. It uses the error-first convention: the callback receives an error as the first argument and the result as the second. It works, but the nesting and the repeated error checks make the code harder to read as the number of operations grows.
The promise problem. The promise API wraps the callback in a Promise, which works with async/await. The result reads like synchronous code, the error handling is a single try/catch, and the code composes with Promise.all and other promise utilities. This is the recommended API for application code.
The thread pool problem. File operations do not have a portable non-blocking OS primitive, so libuv runs them on the thread pool. The default pool size is four threads. A synchronous read does not use the pool; it blocks the event loop directly. An asynchronous read uses the pool, and the event loop continues.
The use-case problem. The three forms map to three use cases. A script or a startup routine uses the synchronous form because it runs before the server is serving requests and the simplicity is worth the blocking. A request handler uses the promise form because the event loop must stay responsive. A library that must support older Node.js versions uses the callback form because the promise API is newer.
a. The synchronous API
The synchronous methods end in Sync. They return the result directly and throw on error.
const fs = require('node:fs');
const data = fs.readFileSync('file.txt', 'utf8');
console.log(data);
The readFileSync call blocks the thread until the file is read. The next line runs only after the data is available. If the file does not exist, the call throws an Error with a code property like ENOENT.
Other synchronous methods:
fs.writeFileSync('output.txt', 'content');
fs.appendFileSync('log.txt', 'line\n');
fs.mkdirSync('dir', { recursive: true });
fs.readdirSync('.');
fs.statSync('file.txt');
fs.unlinkSync('file.txt');
The synchronous API is appropriate when:
| Situation | Why |
|---|---|
| Startup configuration | The server is not serving requests yet |
| Command-line scripts | Blocking does not affect other work |
| Build tools | The tool’s job is to complete the task |
| Tests | Simplicity is worth more than concurrency |
The synchronous API is inappropriate in a request handler, in an event listener, or anywhere the code runs while the server is serving requests.
b. The callback API
The callback API takes a function as the last argument. The function receives an error as the first argument and the result as the second.
const fs = require('node:fs');
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
The readFile call returns immediately. The callback runs when the read completes. The error-first convention requires the callback to check err before using data.
Other callback methods follow the same pattern:
fs.writeFile('output.txt', 'content', (err) => {
if (err) throw err;
});
fs.mkdir('dir', { recursive: true }, (err) => {
if (err) throw err;
});
The callback API is what the promise API wraps. It is still used in legacy code and in libraries that predate the promise API. For new code, the promise API is preferred.
The nesting problem appears when sequential operations are needed:
fs.readFile('a.txt', 'utf8', (err, a) => {
if (err) return console.error(err);
fs.readFile('b.txt', 'utf8', (err, b) => {
if (err) return console.error(err);
fs.writeFile('c.txt', a + b, (err) => {
if (err) return console.error(err);
console.log('done');
});
});
});
Each step nests one level deeper, and the error check repeats at every level. This is the structure that motivated the promise API.
c. The promise API
The promise API lives in node:fs/promises. It returns Promise objects and works with async/await.
const { readFile } = require('node:fs/promises');
async function main() {
try {
const data = await readFile('file.txt', 'utf8');
console.log(data);
} catch (err) {
console.error(err);
}
}
main();
The readFile call returns a promise. The await suspends the async function until the promise settles, and the event loop continues processing other work. On success, data holds the contents. On failure, the catch block receives the error.
Other promise methods:
const { writeFile, appendFile, mkdir, readdir, stat, unlink } = require('node:fs/promises');
await writeFile('output.txt', 'content');
await appendFile('log.txt', 'line\n');
await mkdir('dir', { recursive: true });
const files = await readdir('.');
const stats = await stat('file.txt');
await unlink('file.txt');
The promise API is the recommended form for application code. The async/await syntax reads like synchronous code, the error handling is a single try/catch, and the operations compose with Promise.all and other promise utilities.
Sequential operations do not nest:
async function combine() {
const a = await readFile('a.txt', 'utf8');
const b = await readFile('b.txt', 'utf8');
await writeFile('c.txt', a + b);
}
Each line is a step, and the error handling is a single try/catch around the whole function.
d. Error handling in each form
The three forms handle errors differently.
Synchronous errors are thrown. The caller uses try/catch or lets the error propagate.
try {
const data = fs.readFileSync('file.txt', 'utf8');
} catch (err) {
console.error('Read failed:', err.message);
}
Callback errors are passed to the callback as the first argument. The callback checks err before using the result.
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error('Read failed:', err.message);
return;
}
console.log(data);
});
Promise errors become rejections. The caller uses try/catch with await or .catch() on the promise.
try {
const data = await readFile('file.txt', 'utf8');
} catch (err) {
console.error('Read failed:', err.message);
}
The Error object has a code property that identifies the kind of failure:
| Code | Meaning |
|---|---|
ENOENT | File or directory does not exist |
EACCES | Permission denied |
EISDIR | Expected a file, found a directory |
ENOTDIR | Expected a directory, found a file |
EEXIST | File already exists |
EMFILE | Too many open files |
try {
const data = await readFile('file.txt', 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
console.log('File not found');
} else {
throw err;
}
}
The code check is the way to distinguish “the file is missing, which is expected” from “something else went wrong.”
e. Performance characteristics
The three forms have different performance profiles.
| Form | Blocks event loop | Uses thread pool | Latency |
|---|---|---|---|
| Synchronous | Yes | No | Same as the read |
| Callback | No | Yes | Same as the read |
| Promise | No | Yes | Same as the read |
The synchronous form blocks the event loop for the duration of the read. If the read takes 50 milliseconds, the entire process is idle for 50 milliseconds, and no other JavaScript runs.
The asynchronous forms (callback and promise) delegate the blocking read to the thread pool. The event loop continues processing other work. When the read completes, the callback or the promise resolution runs on the event loop.
The thread pool has four threads by default. If more than four file operations are in progress at once, the additional operations queue. For a server that reads many files concurrently, increasing UV_THREADPOOL_SIZE may help.
UV_THREADPOOL_SIZE=16 node server.js
The promise API adds a small overhead for the promise machinery compared to the callback API, but the difference is negligible compared to the cost of the file operation itself.
The rule is simple: use the synchronous form only when blocking does not matter. In any code that runs while the server is serving requests, use the promise form.
f. Choosing the right form
The choice depends on where the code runs and what it needs to do.
| Situation | Recommended form |
|---|---|
| Startup configuration | Synchronous |
| Command-line script | Synchronous |
| Build tool | Synchronous |
| Test setup | Synchronous |
| Request handler | Promise |
| Event listener | Promise |
| Concurrent file operations | Promise with Promise.all |
| Legacy library integration | Callback |
| Library supporting old Node.js | Callback |
The synchronous form is correct in code that runs before the server starts serving requests. The blocking is harmless because there is nothing else to do.
The promise form is correct in code that runs while the server is serving requests. The blocking is delegated to the thread pool, and the event loop stays responsive.
The callback form is correct when the code must support older Node.js versions or when integrating with a library that uses the callback convention. For new code, the promise form is preferred.
A common pattern is to use the synchronous form at startup and the promise form everywhere else:
// startup.js — synchronous is fine
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
// request.js — promise is required
app.get('/data', async (req, res) => {
try {
const data = await readFile('data.json', 'utf8');
res.send(data);
} catch (err) {
res.status(500).send(err.message);
}
});
The two forms coexist in the same project. The choice is made per call site, based on whether the code blocks the event loop at a time when it matters.
Complete Example Session
// ============================================
// PART 1: SYNCHRONOUS READ
// ============================================
const fs = require('node:fs');
const data = fs.readFileSync('file.txt', 'utf8');
console.log(data);
// ============================================
// PART 2: SYNCHRONOUS ERROR HANDLING
// ============================================
try {
const data = fs.readFileSync('file.txt', 'utf8');
console.log(data);
} catch (err) {
console.error('Read failed:', err.message);
}
// ============================================
// PART 3: CALLBACK READ
// ============================================
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});
// ============================================
// PART 4: CALLBACK NESTING
// ============================================
fs.readFile('a.txt', 'utf8', (err, a) => {
if (err) return console.error(err);
fs.readFile('b.txt', 'utf8', (err, b) => {
if (err) return console.error(err);
fs.writeFile('c.txt', a + b, (err) => {
if (err) return console.error(err);
console.log('done');
});
});
});
// ============================================
// PART 5: PROMISE READ
// ============================================
const { readFile } = require('node:fs/promises');
async function main() {
const data = await readFile('file.txt', 'utf8');
console.log(data);
}
main();
// ============================================
// PART 6: PROMISE ERROR HANDLING
// ============================================
try {
const data = await readFile('file.txt', 'utf8');
console.log(data);
} catch (err) {
if (err.code === 'ENOENT') {
console.log('File not found');
} else {
throw err;
}
}
// ============================================
// PART 7: SEQUENTIAL PROMISE OPERATIONS
// ============================================
async function combine() {
const a = await readFile('a.txt', 'utf8');
const b = await readFile('b.txt', 'utf8');
await writeFile('c.txt', a + b);
}
// ============================================
// PART 8: CONCURRENT PROMISE OPERATIONS
// ============================================
const [a, b, c] = await Promise.all([
readFile('a.txt', 'utf8'),
readFile('b.txt', 'utf8'),
readFile('c.txt', 'utf8'),
]);
// ============================================
// PART 9: STARTUP SYNCHRONOUS, RUNTIME PROMISE
// ============================================
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
app.get('/data', async (req, res) => {
try {
const data = await readFile('data.json', 'utf8');
res.send(data);
} catch (err) {
res.status(500).send(err.message);
}
});
// ============================================
// PART 10: INCREASE THREAD POOL SIZE
// ============================================
// UV_THREADPOOL_SIZE=16 node server.js
console.log(process.env.UV_THREADPOOL_SIZE || 'default: 4');
These ten parts cover the synchronous read, synchronous error handling, the callback read, callback nesting, the promise read, promise error handling, sequential promise operations, concurrent promise operations, the startup-synchronous pattern, and increasing the thread pool size.
Quick Reference
The Three Forms
| Form | Import | Blocking | Error |
|---|---|---|---|
| Synchronous | node:fs | Yes | Throws |
| Callback | node:fs | No | Error-first argument |
| Promise | node:fs/promises | No | Rejection |
Method Names
| Operation | Sync | Callback | Promise |
|---|---|---|---|
| Read | readFileSync | readFile | readFile |
| Write | writeFileSync | writeFile | writeFile |
| Append | appendFileSync | appendFile | appendFile |
| Mkdir | mkdirSync | mkdir | mkdir |
| Readdir | readdirSync | readdir | readdir |
| Stat | statSync | stat | stat |
| Unlink | unlinkSync | unlink | unlink |
Error Codes
| Code | Meaning |
|---|---|
ENOENT | File not found |
EACCES | Permission denied |
EISDIR | Is a directory |
ENOTDIR | Not a directory |
EEXIST | Already exists |
EMFILE | Too many open files |
When to Use Each
| Situation | Form |
|---|---|
| Startup | Synchronous |
| Script | Synchronous |
| Request handler | Promise |
| Event listener | Promise |
| Legacy code | Callback |
| Concurrent operations | Promise with Promise.all |
Performance
| Form | Event loop | Thread pool |
|---|---|---|
| Synchronous | Blocked | Not used |
| Callback | Free | Used |
| Promise | Free | Used |
Best Practices
✅ Do This:
// Use the promise API in request handlers
const { readFile } = require('node:fs/promises');
const data = await readFile('file.txt', 'utf8');
// Use the synchronous API at startup
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
// Handle errors with the code property
try { await readFile('file.txt', 'utf8'); }
catch (err) { if (err.code === 'ENOENT') { } }
// Run concurrent operations with Promise.all
const [a, b] = await Promise.all([readFile('a'), readFile('b')]);
// Increase the thread pool for file-heavy workloads
// UV_THREADPOOL_SIZE=16 node server.js
❌ Don’t Do This:
// Use the synchronous API in a request handler
app.get('/', (req, res) => {
const data = fs.readFileSync('file.txt'); // ❌ blocks
});
// Nest callbacks for sequential operations
fs.readFile('a', (err, a) => {
fs.readFile('b', (err, b) => { // ❌ use promises
fs.writeFile('c', a + b, () => { });
});
});
// Ignore the error
fs.readFile('file.txt', 'utf8', (err, data) => {
console.log(data); // ❌ err ignored
});
// Use the callback API for new code
fs.readFile('file.txt', 'utf8', (err, data) => { }); // ❌ prefer promises
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Server unresponsive | Synchronous read in a handler | Use the promise API |
ENOENT crash | No error handling | Use try/catch |
| Callback hell | Sequential callbacks | Use async/await |
| Thread pool saturation | More than 4 concurrent reads | Increase UV_THREADPOOL_SIZE |
EACCES error | File permissions | Check permissions |
| Data undefined | Error used the result | Check err first |
Real-World Examples
1. Startup Config
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
2. Request Handler
app.get('/data', async (req, res) => {
const data = await readFile('data.json', 'utf8');
res.send(data);
});
3. CLI Script
const input = fs.readFileSync(process.argv[2], 'utf8');
console.log(input.toUpperCase());
4. Concurrent Reads
const [a, b, c] = await Promise.all([
readFile('a.txt', 'utf8'),
readFile('b.txt', 'utf8'),
readFile('c.txt', 'utf8'),
]);
5. Write and Append
await writeFile('output.txt', 'content');
await appendFile('log.txt', 'line\n');
6. Missing File Fallback
try {
return await readFile('cache.json', 'utf8');
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
7. Recursive Mkdir
await mkdir('a/b/c', { recursive: true });
8. List Directory
const files = await readdir('.');
9. File Stats
const stats = await stat('file.txt');
if (stats.isFile()) { }
10. Delete File
await unlink('file.txt');
Visual
The Three Forms
┌──────────────────────────────────────────────────────────────┐
│ SYNCHRONOUS: │
│ const data = readFileSync('file.txt'); │
│ └── Blocks the event loop until the read completes │
│ │
│ CALLBACK: │
│ readFile('file.txt', (err, data) => { }); │
│ └── Returns immediately, callback runs later │
│ │
│ PROMISE: │
│ const data = await readFile('file.txt'); │
│ └── Returns immediately, await suspends the async function │
└──────────────────────────────────────────────────────────────┘
Event Loop and Thread Pool
┌──────────────────────────────────────────────────────────────┐
│ SYNCHRONOUS: │
│ Event loop: [────── read ──────][other work] │
│ └── The read blocks everything │
│ │
│ ASYNCHRONOUS: │
│ Event loop: [read]─[other work]─[other work]─[callback] │
│ Thread pool: [───── read ─────] │
│ └── The read runs on a worker thread │
└──────────────────────────────────────────────────────────────┘
Error Handling Comparison
┌──────────────────────────────────────────────────────────────┐
│ SYNCHRONOUS: │
│ try { readFileSync('file.txt'); } │
│ catch (err) { } │
│ │
│ CALLBACK: │
│ readFile('file.txt', (err, data) => { │
│ if (err) { } │
│ }); │
│ │
│ PROMISE: │
│ try { await readFile('file.txt'); } │
│ catch (err) { } │
└──────────────────────────────────────────────────────────────┘
Choosing a Form
┌──────────────────────────────────────────────────────────────┐
│ Does the code run while the server is serving requests? │
│ │ │
│ ├── No ──▶ Synchronous is fine │
│ │ (startup, scripts, tests) │
│ │ │
│ └── Yes ──▶ Use the promise API │
│ (request handlers, event listeners) │
│ │
│ Integrating with legacy callback code? │
│ └── Use the callback API or promisify │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Synchronous | readFileSync, blocks, throws |
| Callback | readFile(path, cb), non-blocking, error-first |
| Promise | readFile(path), non-blocking, rejects |
| Promise import | node:fs/promises |
| Default thread pool | 4 |
| Pool configuration | UV_THREADPOOL_SIZE |
| Startup | Synchronous is fine |
| Runtime | Promise is required |
| Error codes | ENOENT, EACCES, EISDIR |
| Concurrent reads | Promise.all |
Key takeaways:
- The three forms do the same thing but differ in how they use the thread. The synchronous form blocks the event loop. The asynchronous forms delegate the blocking work to the libuv thread pool and keep the event loop free.
- Use the promise API for application code. It works with
async/await, has a single error channel, and composes withPromise.all. - Use the synchronous API only at startup and in scripts. The blocking is harmless when nothing else is running.
- Use the callback API for legacy code. It is the original asynchronous form, and the promise API wraps it.
- The thread pool has four threads by default. More than four concurrent file operations queue. Increase
UV_THREADPOOL_SIZEfor file-heavy workloads. - Error handling differs by form. Synchronous throws, callback receives the error as the first argument, and promise rejects.
- The
codeproperty identifies the failure.ENOENTmeans the file is missing,EACCESmeans permission denied, and so on.
Remember: The choice between synchronous, callback, and promise file operations is a choice about how the thread is used. The synchronous form blocks the event loop, which is fine at startup and in scripts but unacceptable in a request handler. The asynchronous forms delegate the blocking read to the thread pool and keep the event loop responsive. The promise form is the recommended API for new code because it reads like synchronous code and has a single error channel. The callback form is still present in legacy code and libraries. Choose the form based on where the code runs, and use the promise API everywhere the event loop must stay responsive.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!