Node.js File Reading: From Callback Hell to Clean Async/Await
fs is Node's built-in file system module for reading, writing, and manipulating files and folders. JavaScript is a single-threaded language, and fs provides four approaches — synchronous blocking, callback-based asynchronous, Promise, and async/await — that progressively solve the callback hell problem caused by nested asynchronous operations.
1. Two Ways to Import the Module
- Basic
fs: provides synchronous APIs and callback-style asynchronous APIs
import fs from 'fs';
fs/promises: specifically provides Promise-style APIs, compatible with async/await (recommended for production use)
import fs from 'fs/promises';
2. Approach 1: Synchronous Read with readFileSync (Blocks the Main Thread)
Characteristics: code executes sequentially; the entire thread is blocked while reading the file. Not suitable for endpoints or heavy I/O in loops; only suitable for one-time configuration file reads during project startup.
import fs from 'fs';
const syncData = fs.readFileSync('./test.txt', 'utf-8');
console.log(syncData);
Drawback: massive file reads and writes in concurrent scenarios severely degrade service performance.
3. Approach 2: Callback-Based Asynchronous readFile (ES6 Native Style, Callback Hell)
Node follows a unified error-first callback convention: (err, data) => {}
- On success:
errisnull,datacontains the file content - On failure:
erris an error object (file not found, insufficient permissions, etc.)
Reading multiple files serially produces deeply nested callbacks — callback hell — which is extremely costly to maintain.
import fs from 'fs';
fs.readFile('./file1.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file1', data);
} else {
console.log(err);
}
fs.readFile('./file2.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file2', data);
} else {
console.log(err);
}
fs.readFile('./file3.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file3', data);
} else {
console.log(err);
}
})
})
})
4. Approach 3: Promise Chaining (Eliminates Nesting)
Based on fs/promises, readFile returns a Promise. Using .then() chains executes operations serially, solving the deep nesting problem. The drawback is that multi-file scenarios require repetitive .then() calls, making the code redundant.
import fs from 'fs/promises';
fs.readFile('./file1.txt', 'utf-8')
.then(data => {
console.log('file1', data);
return fs.readFile('./file2.txt', 'utf-8');
})
.then(data => {
console.log('file2', data);
return fs.readFile('./file3.txt', 'utf-8');
})
.then(data => {
console.log('file3', data);
})
5. Approach 4: async/await (ES8 Optimal Solution, Top Choice for Production)
async/await is syntactic sugar over Promises. Under the hood it remains asynchronous and does not block the main thread; code is written linearly from top to bottom, offering the strongest readability. Limitation: await can only be used inside an async function; using await at the top level will throw an error. Wrap it in an IIFE (Immediately Invoked Function Expression). Supplement: Promises and await belong to microtasks; setTimeout belongs to macrotasks.
import fs from 'fs/promises';
(async () => {
const file1Data = await fs.readFile('./file1.txt', 'utf-8');
console.log('file1', file1Data);
const file2Data = await fs.readFile('./file2.txt', 'utf-8');
console.log('file2', file2Data);
const file3Data = await fs.readFile('./file3.txt', 'utf-8');
console.log('file3', file3Data);
})();
A complete, robust version (adding try/catch to capture read errors)
import fs from 'fs/promises';
(async () => {
try {
const data = await fs.readFile('./test.txt', 'utf-8');
console.log(data);
} catch (err) {
console.error('File read failed:', err);
}
})();
6. Asynchronous Code Evolution Path
Synchronous blocking (poor performance) → Callback functions (callback hell) → Promise then chaining (redundant) → async/await (concise and maintainable)
7. Comparison Table of the Four Read Approaches
| Approach | Blocks Thread | Advantages | Disadvantages |
|---|---|---|---|
| readFileSync | Yes | Extremely simple syntax | Heavy I/O severely degrades service performance |
| readFile callback | No | No extra imports required | Serial multi-file reads produce callback hell |
| Promise then | No | No nested structure | Repetitive and verbose chaining for multiple files |
| async await | No | Linear reading, clean code | ES8 syntax, requires wrapping in an async function |
8. Engineering Best Practices
- For business file reads and writes, uniformly use
fs/promises + async/await; - All file reads must be paired with try/catch to capture exceptions;
- Synchronous
readFileSyncshould only be used for loading configuration during project initialization; it is forbidden in endpoint business logic; - Pair paths with
path.resolveto generate absolute paths, preventing file-not-found errors caused by changes in the working directory.