Node.js fs — readFileSync Stalled My API Gateway
- Use synchronous fs methods only at application startup before
server.listen()— never inside request handlers, middleware, or timer callbacks, or you block every concurrent request until the disk operation completes. - Prefer the fs/promises API with async/await for all new code — it provides clean try/catch error handling, easy parallel reads with Promise.all, and top-to-bottom readability that survives code review.
- Streams are not optional for large files — readFile loads the entire file into memory, and createReadStream keeps memory flat at the chunk size regardless of file size. If the file size is unknown or user-controlled, use streams.
- The fs module is Node's built-in toolkit for reading, writing, watching, and streaming files on disk
- Sync methods (readFileSync) block the event loop — use them only at startup, never in request handlers
- The fs/promises API with async/await is the modern default for all new code
- Streams (createReadStream/createWriteStream) keep memory flat for files of any size by processing chunks
- Always use path.join(__dirname, 'file') instead of relative paths — CI scripts and npm commands change the working directory
- Biggest mistake: calling readFileSync inside an HTTP handler under load, blocking every other request until the disk read completes
fs Module Quick Debug
Event loop stalled — requests queuing
grep -rn 'Sync(' src/ | grep -E 'readFileSync|writeFileSync|statSync|readdirSync|appendFileSync'node -e "const {monitorEventLoopDelay}=require('perf_hooks'); const h=monitorEventLoopDelay({resolution:10}); h.enable(); setInterval(()=>console.log('lag:',(h.mean/1e6).toFixed(1)+'ms'),2000)"ENOENT: no such file or directory
node -e "console.log('cwd:', process.cwd()); console.log('__dirname:', __dirname)"ls -la <the-failing-path>Process out of memory reading a large file
ls -lh <the-file>node -e "const m=process.memoryUsage(); console.log({rss: Math.round(m.rss/1024/1024)+'MB', heap: Math.round(m.heapUsed/1024/1024)+'MB'})"EACCES: permission denied on file operation
ls -la <the-failing-path>node -e "console.log('uid:', process.getuid && process.getuid(), 'gid:', process.getgid && process.getgid())"Production Incident
server.listen(). One readFileSync call under disk I/O pressure blocks every other request in the process simultaneously.Cache file reads in memory with a TTL if the file changes infrequently. Reading a feature flag file from disk on every request is wasteful even when the read is async — 400 identical reads per second of the same file is just unnecessary I/O.Monitor event loop lag in production using perf_hooks.monitorEventLoopDelay. A healthy Node.js process has event loop lag under 10ms. If it exceeds 100ms, something is blocking the loop — and readFileSync is the single most common culprit in production API servers.Test under realistic I/O conditions. A readFileSync that takes 2ms on an NVMe SSD during development takes 200ms on a throttled EBS volume at peak traffic. Your laptop is not production, and neither is a lightly loaded staging environment.Production Debug GuideSymptom-driven actions for diagnosing file system performance, errors, and memory issues
process.cwd(), which differs between environments. Check whether the failing code uses './filename' or a string-concatenated path instead of path.join(__dirname, 'filename'). In Docker containers, the WORKDIR directive determines cwd, which may not match the directory where the script lives. In CI/CD pipelines, npm scripts change cwd to the package root before running. Always use __dirname-based paths for any file your application needs to find consistently regardless of where the process is launched from.Every serious backend application needs to talk to the file system. Log files that capture what went wrong at 3 AM, config files that change how your app behaves per environment, user-uploaded profile pictures, CSV exports a client downloads on Friday afternoon — all of that flows through file I/O. The built-in fs module is how Node.js gets this done.
The critical distinction most developers learn too late is the difference between synchronous and asynchronous file operations. This distinction is not about style or API preference — it is about whether your server stays responsive or grinds to a halt under load. A single readFileSync call inside an HTTP request handler blocks the entire event loop. Under normal traffic this might add 2 to 8ms to your response time, which looks acceptable in testing. Under real load on cloud storage with throttled IOPS, that same call can block for 200ms or more, and every other request in the process queues behind it. That is how a slow config file read turns into a cascading outage.
I have debugged this exact incident more than once — most recently in an API gateway that served as the entry point for a dozen downstream services. The fix was three lines of code. The diagnosis took 20 minutes of looking in the wrong place first.
By the end of this article you will understand when sync is acceptable and when it is a production hazard, how to use the Promise-based fs/promises API for clean async code, how streams keep memory flat for gigabyte-scale files, and the specific production mistakes that silently degrade performance or corrupt data before you notice them.
Sync vs Async File Operations — Why the Difference Can Make or Break Your App
The fs module gives you two personalities for almost every operation: a synchronous version that blocks the entire Node.js process until the disk work is done, and an asynchronous version that kicks off the work and continues executing other code while the OS handles it.
The synchronous API — readFileSync, writeFileSync, statSync, and their kin — is dead simple. You call the function, you get the result back immediately, you continue. The appeal is obvious, especially to developers coming from synchronous languages. But 'blocking' is the operative word. While Node.js is waiting for the disk to respond, it cannot handle any other requests. Not one. In a web server handling hundreds of simultaneous users, one slow disk read serializes everything behind it. That is not a theoretical concern — it is the root cause of real outages, including the incident described above.
The asynchronous API fits Node's event loop architecture. You request the file operation, Node hands it to the OS, and while the disk is working, the event loop continues handling other requests, timers, and I/O callbacks. When the file is ready, your callback fires or your Promise resolves. This is the foundational reason async exists — responsiveness under concurrent load.
The rule is simple enough to memorize: sync operations are acceptable during application startup, before your server begins accepting connections. Reading a config file before server.listen() is fine — the server is not handling requests yet, and failing fast on a missing config is correct behavior. After server.listen(), use async exclusively. The line is that clear.
const fs = require('fs'); const path = require('path'); const configFilePath = path.join(__dirname, 'app-config.json'); // ─── SYNCHRONOUS — blocks the entire thread until the disk read completes ─── // This is acceptable here because we are at startup, before server.listen(). // If the config is missing or unreadable, we want to crash loudly with a clear // error message — not silently proceed with undefined configuration. try { const rawConfig = fs.readFileSync(configFilePath, 'utf8'); // returns a string directly const config = JSON.parse(rawConfig); console.log('Config loaded synchronously:', config.appName); } catch (startupError) { // Crash at startup with a useful message — much better than crashing // in a request handler later with a confusing ENOENT mid-traffic. console.error('Could not load config. Aborting startup:', startupError.message); process.exit(1); } // ─── ASYNCHRONOUS — hands I/O to the OS, returns control to the event loop ─── // Use this inside request handlers, scheduled jobs, and anywhere after startup. // The event loop stays free to handle other work while the disk responds. fs.readFile(configFilePath, 'utf8', (readError, fileContents) => { // Node's error-first callback pattern: the first argument is always the error // (or null on success). Always check it. Always return after handling it. if (readError) { console.error('Async read failed:', readError.message); return; // do not let execution fall through to the JSON.parse below } const config = JSON.parse(fileContents); console.log('Config loaded asynchronously:', config.appName); }); // This line executes immediately after fs.readFile() registers the I/O request — // before the disk responds. This is the proof that the event loop stayed free. console.log('This line prints BEFORE the async callback fires — proof the thread is free');
This line prints BEFORE the async callback fires — proof the thread is free
Config loaded asynchronously: MyApp
- readFileSync blocks the entire event loop until the disk read completes — no other request is processed during that time, full stop.
- readFile hands the I/O request to the OS and returns control immediately — the callback fires when the data is ready.
- Under disk I/O pressure on cloud storage (EBS, network-attached volumes), a readFileSync that takes 2ms on an SSD takes 200ms or more on a throttled volume.
- Safe sync usage: config loading at startup, before
server.listen(). Never inside request handlers, timer callbacks, or event listeners. - The output order in the example proves it: the line after readFile executes before the callback. Never assume top-to-bottom execution order with async code.
server.listen() fires, if you find readFileSync in a code path, treat it as a production bug regardless of how fast it runs in development.server.listen(). Use fs/promises for everything that runs after.server.listen() at startupThe Promise-Based fs/promises API — Cleaner Code With async/await
The callback-based fs.readFile works, but nested callbacks compound quickly. By the time you are reading a config file, checking it for validity, then writing a processed version to a different location, the code looks like a pyramid, error handling duplicates itself at every level, and adding another step means indenting the entire thing again. The community named this 'callback hell' and it is not an exaggeration.
Node.js 10 shipped fs/promises — a Promise-based twin of the fs module that works naturally with async/await. This is not a third-party library or a wrapper — it is built into Node's core and carries no additional overhead beyond the Promise machinery that your code is already using.
The practical benefits go beyond aesthetics. With async/await and try/catch, one error handler covers the entire operation regardless of how many file reads it involves. With Promise.all, you can read multiple files in parallel rather than sequentially — and on spinning disks or network-attached storage where seek time matters, the difference between parallel and sequential reads is measurable. A startup sequence that reads three config files takes the time of the slowest single read with Promise.all, versus the sum of all three reads done in sequence.
There is also a naming note worth addressing: require('fs').promises and require('fs/promises') are identical. Both give you the same API. Pick one style and make it consistent across the codebase — mixing them creates unnecessary confusion for anyone reading the code later.
// The modern approach — fs/promises with async/await for all new code const { readFile, writeFile, mkdir, access, constants } = require('fs/promises'); const path = require('path'); // ─── Real-world pattern: load multiple config files in parallel at startup ─── // This is the correct way to do multi-file startup loading. // Sequential awaits would take the sum of all read times. // Promise.all takes only the time of the slowest single read. async function loadApplicationConfig() { const configDir = path.join(__dirname, 'config'); // Verify the config directory exists before attempting reads — // a clearer error message than the ENOENT that would follow. try { await access(configDir, constants.R_OK); } catch { throw new Error(`Config directory not found or not readable: ${configDir}`); } try { // All three reads start simultaneously — none waits for the others to finish. // If any one fails, the entire Promise.all rejects and the catch block fires. const [dbConfigRaw, serverConfigRaw, featureFlagsRaw] = await Promise.all([ readFile(path.join(configDir, 'database.json'), 'utf8'), readFile(path.join(configDir, 'server.json'), 'utf8'), readFile(path.join(configDir, 'features.json'), 'utf8'), ]); // Parse only after confirming all three reads succeeded. // JSON.parse is synchronous — keep it outside the async boundary. const dbConfig = JSON.parse(dbConfigRaw); const serverConfig = JSON.parse(serverConfigRaw); const featureFlags = JSON.parse(featureFlagsRaw); console.log('All configs loaded in parallel'); console.log(` DB host: ${dbConfig.host}`); console.log(` Server port: ${serverConfig.port}`); console.log(` Dark mode: ${featureFlags.darkMode}`); return { dbConfig, serverConfig, featureFlags }; } catch (loadError) { // One try/catch covers all three reads — if any file is missing or // unreadable, this fires with a clear error. No nested callbacks, // no separate error checks per file. console.error('Config load failed:', loadError.message); throw loadError; // re-throw so the caller knows startup failed } } // ─── Writing a file — creating nested output directories first ─── // The { recursive: true } option on mkdir is the Node.js equivalent of // 'mkdir -p' — it creates all missing parent directories and does not // throw if the directory already exists. async function saveProcessedReport(reportData) { const outputDir = path.join(__dirname, 'reports'); const outputFile = path.join(outputDir, `report-${Date.now()}.json`); try { await mkdir(outputDir, { recursive: true }); // JSON.stringify with indent=2 produces human-readable output — // useful for reports that operations teams might read directly. await writeFile(outputFile, JSON.stringify(reportData, null, 2), 'utf8'); console.log(`Report saved to: ${outputFile}`); return outputFile; } catch (writeError) { console.error('Failed to save report:', writeError.message); throw writeError; } } (async () => { const config = await loadApplicationConfig(); await saveProcessedReport({ totalOrders: 1482, revenue: 94300, currency: 'USD' }); })();
DB host: db.prod.internal
Server port: 3000
Dark mode: true
Report saved to: /app/reports/report-1718200000000.json
process.cwd(), not the script's location. __dirname is always the directory containing the current file, regardless of where the process was launched from. This is not a style preference. It is the difference between code that works everywhere and code that works only on your machine.Streaming Large Files — How to Handle Gigabytes Without Running Out of Memory
Reading a 10 KB config file with readFile is completely fine. Reading a 2 GB log file the same way will crash your server. When you call readFile on a large file, Node loads the entire file into memory as a Buffer before your callback or Promise resolution fires. On a server with 512 MB of RAM, a 2 GB file never makes it — the process runs out of memory and the OOM killer terminates it.
Streams solve this by processing the file in small chunks rather than loading it all at once. The chunk size defaults to 64 KB. Memory usage is roughly constant at that chunk size regardless of how large the source file is — whether the file is 100 MB or 100 GB, the process holds approximately 64 KB of data at any given moment during processing.
The mechanism behind this is backpressure. Streams do not blindly read ahead and buffer data faster than the consumer can process it. The readable stream checks whether the writable stream is ready before reading the next chunk. If the writer falls behind — because it is uploading to S3, compressing through gzip, or writing to a slow disk — the reader pauses until the writer catches up. This coordination happens automatically when you use pipe() or the pipeline() API from stream/promises.
The most powerful pattern is piping: connecting a readable stream through transforms to a writable destination. A log file piped through zlib.createGzip() to a write stream compresses the file chunk by chunk without the full content ever existing in memory simultaneously. This is how build tools, log rotation jobs, and data pipelines work at scale.
The rule is practical: if the file size is unknown, user-controlled, or potentially large, streams are not optional. They are the only approach that does not carry OOM risk.
const fs = require('fs'); const zlib = require('zlib'); const readline = require('readline'); const path = require('path'); const { pipeline } = require('stream/promises'); const sourceLogFile = path.join(__dirname, 'server.log'); const compressedOutput = path.join(__dirname, 'server.log.gz'); // ─── Pattern 1: Compress a large file with pipeline() from stream/promises ─── // pipeline() is preferred over pipe() in production because it: // - propagates errors from any stream in the chain // - destroys all streams on failure (preventing file descriptor leaks) // - returns a Promise compatible with async/await async function compressLogFile(source, destination) { const readStream = fs.createReadStream(source); // reads in ~64KB chunks const gzipStream = zlib.createGzip({ level: 6 }); // compress each chunk const writeStream = fs.createWriteStream(destination); // pipeline() wires backpressure between all three streams automatically. // If gzip falls behind the reader, the reader pauses. No manual event wiring needed. await pipeline(readStream, gzipStream, writeStream); console.log(`Compressed: ${source} -> ${destination}`); return destination; } // ─── Pattern 2: Process a large text file line-by-line with readline ─── // readline + createReadStream is the memory-efficient way to handle // CSV, NDJSON, log files, or any line-structured text file. // Memory cost: one line at a time, not the entire file. async function countLinesInLargeFile(filePath) { const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); const lineInterface = readline.createInterface({ input: fileStream, crlfDelay: Infinity, // treat \r\n as a single newline — handles Windows line endings }); let lineCount = 0; let errorCount = 0; // for-await-of on readline gives you one line per iteration. // The loop pauses between iterations — readline and createReadStream // handle backpressure so the file is not read faster than lines are processed. for await (const line of lineInterface) { lineCount++; // Example: count malformed JSON lines in an NDJSON log file if (line.trim()) { try { JSON.parse(line); } catch { errorCount++; } } } console.log(`Lines processed: ${lineCount.toLocaleString()}`); console.log(`Parse errors: ${errorCount.toLocaleString()}`); return { lineCount, errorCount }; } (async () => { await compressLogFile(sourceLogFile, compressedOutput); const { lineCount, errorCount } = await countLinesInLargeFile(sourceLogFile); await fs.promises.writeFile( path.join(__dirname, 'summary.json'), JSON.stringify({ lineCount, errorCount, processedAt: new Date().toISOString() }, null, 2), 'utf8' ); })();
Lines processed: 4,821,304
Parse errors: 12
pipeline() from stream/promises instead of pipe() — it propagates errors from all streams, destroys streams on failure, and returns a Promise. pipe() silently swallows errors from transform streams in ways that are very difficult to debug.pipeline() handles this automatically.pipeline() from stream/promises rather than bare pipe() for production code.Watching Files and Managing Directories — Patterns for Build Tools and Dev Servers
Beyond reading and writing, the fs module lets you watch files for changes and manage directory structures programmatically. These capabilities are the foundation of development tooling — Nodemon, Webpack's watch mode, TypeScript's watch compiler, and any CI pipeline that reacts to file system events all use file watching at their core.
fs.watch is Node's built-in watcher. It is lightweight and requires no installation, but it has documented quirks that catch engineers off guard. Text editors perform atomic saves — they write to a temporary file, then rename it over the original — which produces two to three fs.watch events per logical save. On macOS, fs.watch uses kqueue internally and has known limitations with certain directory structures. On Linux it uses inotify, which has configurable limits on the number of watched files (fs.inotify.max_user_watches) that can be hit in large monorepos. On Windows, behavior differs again. For file watching logic that is core to your product rather than a development convenience, chokidar is the pragmatic choice — it normalizes all of these OS differences behind a consistent API.
For directory management, the operations you reach for most often are mkdir with { recursive: true } and rm with { recursive: true, force: true }. These are the safe, cross-platform equivalents of mkdir -p and rm -rf. The recursive flag on mkdir means it creates all missing parent directories without throwing if any of them already exist. The force flag on rm means it does not throw if the target directory does not exist — useful for cleaning a build output directory that may or may not have been created by a previous run.
fs.promises.stat is underused but valuable. Before attempting to write to a path, checking whether it is a file or a directory prevents confusing ENOENT or EISDIR errors. Before starting a file watch, checking that the target exists prevents silent watcher failures.
const fs = require('fs'); const path = require('path'); // ─── Pattern 1: Watch a config file and reload on change ─── // The debounce is not optional — without it, a single save fires // the callback 2-3 times due to atomic write patterns in text editors. function watchConfigFile(filePath, onChangeCallback) { // Verify the file exists before attaching the watcher. // A watcher on a non-existent file silently does nothing on some OSes. try { fs.accessSync(filePath, fs.constants.R_OK); } catch { throw new Error(`Cannot watch non-existent or unreadable file: ${filePath}`); } console.log(`Watching for changes: ${filePath}`); let debounceTimer = null; const watcher = fs.watch(filePath, (eventType) => { // Clear any previously scheduled callback — if another event arrives // within 100ms, we start the timer over. clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { console.log(`File changed (${eventType}), reloading...`); onChangeCallback(filePath); }, 100); // 100ms covers the window for most editor atomic save sequences }); // Return the watcher so the caller can close it during graceful shutdown. // An unclosed watcher prevents the event loop from exiting naturally. return watcher; } // ─── Pattern 2: Safe directory creation (mkdir -p equivalent) ─── async function ensureOutputDirectory(dirPath) { try { const stats = await fs.promises.stat(dirPath); if (!stats.isDirectory()) { // A FILE at this path is a real problem — mkdir would fail with EEXIST // and the error message would not explain why. throw new Error(`Path exists but is a file, not a directory: ${dirPath}`); } console.log(`Output directory already exists: ${dirPath}`); } catch (statError) { if (statError.code === 'ENOENT') { // Path does not exist — create it and all missing parents await fs.promises.mkdir(dirPath, { recursive: true }); console.log(`Created output directory: ${dirPath}`); } else { throw statError; // EACCES, ENOTDIR, or something unexpected — bubble it up } } } // ─── Pattern 3: Clean a build directory between runs ─── // { recursive: true, force: true } is rm -rf — won't throw if the directory // doesn't exist. Create it fresh immediately after removal. async function cleanBuildDirectory(buildDir) { await fs.promises.rm(buildDir, { recursive: true, force: true }); await fs.promises.mkdir(buildDir, { recursive: true }); console.log(`Build directory cleaned and recreated: ${buildDir}`); } (async () => { const buildDir = path.join(__dirname, 'dist'); const configFile = path.join(__dirname, 'app-config.json'); await cleanBuildDirectory(buildDir); await ensureOutputDirectory(path.join(buildDir, 'assets')); await ensureOutputDirectory(path.join(buildDir, 'assets', 'images')); const watcher = watchConfigFile(configFile, async (changedFile) => { console.log(`Re-reading config from: ${changedFile}`); // In a real app: invalidate the in-memory config cache here }); // Close the watcher on SIGINT (Ctrl+C) and SIGTERM (process manager shutdown). // Without this, open watchers prevent the process from exiting cleanly, // which causes test suites to hang after completion. const shutdown = () => { watcher.close(); console.log('File watcher closed. Shutting down.'); process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); })();
Created output directory: /app/dist/assets
Created output directory: /app/dist/assets/images
Watching for changes: /app/app-config.json
File changed (change), reloading...
Re-reading config from: /app/app-config.json
File watcher closed. Shutting down.
File Permissions, Ownership, and Cross-Platform Gotchas
File system errors in production often have nothing to do with your code logic — they are about who owns the file, what permissions are set, and how the operating system interprets the path you provided. These issues are invisible in development where you run as your personal user with broad file system access, and they surface consistently in Docker containers, CI runners, and production servers where the Node.js process runs as a restricted non-root user.
The three error codes you will encounter most often are ENOENT, EACCES, and EBUSY. Each represents a fundamentally different problem that requires a different response. ENOENT means the path does not exist — this is often a path resolution bug, frequently caused by using relative paths that resolve correctly from the project root but fail when the process starts from a different directory. EACCES means the process user does not have the required permission on the target file or directory — in Docker, this happens when files are created by the host user and the container runs as a different UID. EBUSY means the file is exclusively locked by another process — common on Windows and occasionally on Linux with certain file systems.
Cross-platform path handling is the other reliable source of environment-specific bugs. Windows uses backslashes as path separators; Unix uses forward slashes. String concatenation with slashes breaks on at least one platform. path.join and path.resolve handle separator normalization automatically and are the only correct approach for building file paths in code that runs on more than one operating system. In 2026, with Node.js applications routinely developed on macOS and deployed on Linux via Docker, using the path module is simply table stakes.
There is also a category of timing-related errors worth knowing: ENOENT can occur during write operations not because the file is missing, but because a parent directory in the path does not exist. The fix is to call mkdir with recursive: true before writing — a pattern worth building into any utility function that writes to configurable output paths.
const fs = require('fs'); const path = require('path'); // ─── Pattern: handle fs errors by error code, not by message ─── // err.message changes between Node.js versions and OS platforms. // err.code is stable — it is the correct thing to switch on. async function safeFileRead(filePath) { try { const content = await fs.promises.readFile(filePath, 'utf8'); return { success: true, content }; } catch (err) { switch (err.code) { case 'ENOENT': // The file does not exist. This is often recoverable — // return a default, create the file, or log a warning. // Do not throw here unless missing file is fatal to the operation. console.warn(`File not found: ${filePath} — using defaults`); return { success: false, reason: 'NOT_FOUND', fallback: true }; case 'EACCES': // Permission denied. The process user cannot read this file. // This requires an infrastructure fix (chown, Dockerfile USER) — // there is nothing the application can do at runtime to recover. console.error( `Permission denied: ${filePath}. ` + `Check file ownership (ls -la) and process UID (process.getuid())` ); return { success: false, reason: 'PERMISSION_DENIED', fallback: false }; case 'EBUSY': // File is locked by another process — most common on Windows // or with file systems that use exclusive locks. // Retry after a short delay is the appropriate response. console.error(`File busy: ${filePath} — another process has it locked. Retry after delay.`); return { success: false, reason: 'FILE_BUSY', fallback: false }; case 'EISDIR': // The path points to a directory, not a file. // Almost always a path resolution bug. console.error(`Expected a file but found a directory: ${filePath}`); return { success: false, reason: 'IS_DIRECTORY', fallback: false }; default: // Unexpected error — re-throw with context so the caller knows // where it originated. throw err; } } } // ─── Cross-platform path construction ─── // Never concatenate paths with string operators. // path.join normalizes separators for the current OS automatically. const reportPath = path.join(__dirname, 'data', 'report.csv'); console.log('Cross-platform safe path:', reportPath); // On macOS/Linux: /app/data/report.csv // On Windows: C:\app\data\report.csv // Both correct for their OS — path.join handled it. // ─── Create parent directories before writing to avoid ENOENT ─── // A common mistake: writeFile fails with ENOENT not because the file // is missing, but because a parent directory in the path does not exist. async function safeWriteFile(filePath, content) { const parentDir = path.dirname(filePath); // Ensure the entire parent path exists before attempting the write. // { recursive: true } means: create all missing parents, don't fail // if any already exist. await fs.promises.mkdir(parentDir, { recursive: true }); await fs.promises.writeFile(filePath, content, 'utf8'); console.log(`Written: ${filePath}`); } (async () => { const readResult = await safeFileRead('/tmp/missing-config.json'); console.log('Read result:', readResult); await safeWriteFile( path.join(__dirname, 'output', 'nested', 'deep', 'report.json'), JSON.stringify({ status: 'ok', generatedAt: new Date().toISOString() }, null, 2) ); })();
File not found: /tmp/missing-config.json — using defaults
Read result: { success: false, reason: 'NOT_FOUND', fallback: true }
Written: /app/output/nested/deep/report.json
| Aspect | Callback API (fs.readFile) | Promise API (fs/promises) | Stream API (createReadStream) |
|---|---|---|---|
| Syntax style | Error-first callback — (err, data) => {} | async/await with try/catch | Event-driven with pipe() or pipeline() — or for-await-of with readline |
| Error handling | Manual check on first argument — if (err) return. Easy to forget, nests awkwardly | Standard try/catch with switch on err.code — one handler covers all operations in the block | Error event listeners on each stream — or use pipeline() which propagates errors automatically |
| Memory usage | Loads entire file into a single Buffer — memory cost equals file size | Loads entire file into a single Buffer — same memory footprint as callback API | Processes in ~64KB chunks — memory stays constant regardless of file size |
| Parallel operations | Requires async.parallel, counters, or manual coordination across callbacks | Clean Promise.all([...]) — parallel reads in one line, rejects on first failure | Multiple streams run concurrently by default — each manages its own flow independently |
| Code readability | Nests into callback hell for multi-step operations — each step adds a level of indentation | Reads top-to-bottom like synchronous code — easiest to code-review and maintain | Moderate — pipe chains are concise, but error listener wiring is verbose without pipeline() |
| Node.js version | Available since Node 0.x — works everywhere | Stable since Node 10 (v10.0.0) — the default choice for all code written after 2018 | Available since Node 0.x — stable API with ongoing performance improvements |
| When to use | Legacy codebases only — or when integrating with APIs that expect callback style | All new code for files with bounded, known sizes — config, JSON, templates, schemas | Large files, unknown sizes, user uploads, binary transforms, or any pipeline that benefits from backpressure |
| Stack traces | Shallow — context is lost across callback boundaries, making stack traces hard to follow | Full async stack traces in Node 12+ — much easier to trace errors back to their origin | Stream error stack traces can be fragmented — pipeline() from stream/promises helps consolidate them |
🎯 Key Takeaways
- Use synchronous fs methods only at application startup before
server.listen()— never inside request handlers, middleware, or timer callbacks, or you block every concurrent request until the disk operation completes. - Prefer the fs/promises API with async/await for all new code — it provides clean try/catch error handling, easy parallel reads with Promise.all, and top-to-bottom readability that survives code review.
- Streams are not optional for large files — readFile loads the entire file into memory, and createReadStream keeps memory flat at the chunk size regardless of file size. If the file size is unknown or user-controlled, use streams.
- Always use path.join(__dirname, 'file') instead of relative paths —
process.cwd()changes between environments, but __dirname is always the directory containing the current script. - Switch on err.code — ENOENT, EACCES, EBUSY — not err.message. Error messages change between Node.js versions and differ by OS. Error codes are stable and machine-readable.
- Debounce fs.watch callbacks with setTimeout and close watchers in shutdown handlers — text editors fire multiple events per save, and unclosed watchers prevent clean process exit.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat's the difference between fs.readFile and fs.createReadStream, and when would you choose one over the other?Mid-levelReveal
- QIf fs.watch fires multiple events for a single file save, how would you ensure your callback only runs once per logical change?Mid-levelReveal
- QHow would you read a 5GB CSV file in Node.js without causing an out-of-memory crash?JuniorReveal
- QExplain the difference between ENOENT, EACCES, and EBUSY error codes in the fs module, and how you would handle each in production.SeniorReveal
Frequently Asked Questions
What is the Node.js fs module used for?
The fs module is Node's built-in library for interacting with files and directories on the host file system. It lets you read, write, append, delete, rename, copy, stat, and watch files — all from JavaScript running on the server. You access it with require('fs') or require('fs/promises') and it ships with Node.js, so no npm installation is needed. It is the foundation for logging, config loading, file upload handling, build tooling, and any application that needs to persist or retrieve data from disk.
Should I use fs.readFile or fs/promises readFile in 2026?
Use the fs/promises API for all new code. Import it with const { readFile } = require('fs/promises'). It works natively with async/await, gives you standard try/catch error handling, and enables parallel reads with Promise.all without any extra libraries. The callback-based fs.readFile still works and is supported indefinitely, but it leads to harder-to-maintain nested callback code and less clear error handling. Both APIs are built into Node and there is no performance difference — the callback version just has an older, less ergonomic interface.
Why does fs.readFile return a Buffer instead of a string?
Node.js does not assume a file contains text — it might be an image, audio, compiled binary, or any other binary format. So readFile returns a raw Buffer by default, which can represent any sequence of bytes. To get a string instead, pass an encoding as the second argument: fs.promises.readFile('file.txt', 'utf8'). Without the encoding, you get a Buffer and operations like JSON.parse, split, and trim will fail or return unexpected results. This behavior is intentional and correct — binary file handling would be broken if readFile assumed UTF-8 for everything.
When should I use streams instead of readFile?
Use streams whenever the file size is unknown, user-controlled, or potentially larger than the memory you can afford to hold per operation. readFile loads the entire file into a Buffer before your code sees any of it — for a 2 GB log file on a server with 512 MB of available memory, the process runs out of memory and crashes before the read completes. Streams process data in chunks — 64 KB by default — keeping memory usage flat regardless of file size. For text processing, combine createReadStream with readline and for-await-of. For binary transforms, use pipeline() from stream/promises. The practical threshold is roughly 1 MB — below that, readFile is simpler and acceptable; above that, use streams.
How do I handle file paths correctly in cross-platform Node.js applications?
Use path.join() and path.resolve() from the built-in path module for every path you construct in code. Never concatenate paths with '/' or '\' strings — forward slashes work on Unix but not reliably on Windows, and backslashes break on Unix. Use __dirname to get the directory containing the current script, and build paths from there: path.join(__dirname, 'config', 'database.json'). This resolves correctly on both Windows and Unix regardless of the working directory. In ES modules where __dirname is not available, use fileURLToPath(new URL('.', import.meta.url)) as the equivalent.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.