Senior 11 min · March 05, 2026
Node.js File System Module

Node.js fs — readFileSync Stalled My API Gateway

P99 latency spiked to 12 seconds; readFileSync blocked event loop under 400 req/s causing retry storm and 3x load.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Node.js File System Module?

Node.js's fs (file system) module is the standard library for interacting with the host operating system's file I/O. It provides synchronous, callback-based, and promise-based APIs for reading, writing, deleting, and manipulating files and directories.

Imagine your computer's file system is a giant filing cabinet, and Node.js is a new employee who needs to open drawers, read documents, add new folders, and shred old papers.

The module exists because Node.js runs on a single-threaded event loop—any blocking operation like readFileSync halts all other requests, making it a critical performance bottleneck in production APIs. You'd use fs for tasks like serving static assets, reading configuration files, or building CLI tools, but you should avoid it for high-throughput web servers where even a 50ms file read can cascade into timeouts under load.

Alternatives include streaming with fs.createReadStream for large files, or offloading I/O to worker threads when you must use synchronous operations. The fs/promises API (available since Node 10) gives you async/await syntax without callbacks, but it still blocks the event loop if you misuse it with large files.

For real-world context, a single readFileSync call reading a 10MB config file can stall an Express API gateway handling 10,000 requests per second—this is why production Node.js apps almost always use streams or the promise-based API with careful error handling. The module also includes fs.watch for file change detection (used by tools like Webpack and nodemon) and fs.chmod/fs.chown for permission management, though these have cross-platform quirks (e.g., Windows doesn't support Unix-style permissions).

When you need to process gigabytes of log files or serve video content, fs.createReadStream with backpressure handling is the only viable approach—anything else will crash your process with out-of-memory errors.

Plain-English First

Imagine your computer's file system is a giant filing cabinet, and Node.js is a new employee who needs to open drawers, read documents, add new folders, and shred old papers. The fs module is that employee's hands — it's the built-in toolkit Node gives you so your JavaScript code can physically touch files on your hard drive. Without it, your Node app is just talking to itself. With it, you can read config files, save user uploads, write logs, tail a 3 AM error file, and build tools that interact with the real world on disk.

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.

Why fs.readFileSync Blocks Your Event Loop

The Node.js fs (file system) module provides an interface for interacting with the host file system. Its core mechanic is wrapping POSIX file operations (open, read, write, stat) into JavaScript functions. The module exposes three API styles: synchronous (blocking), callback-based asynchronous, and promise-based asynchronous. The synchronous variants — readFileSync, writeFileSync, etc. — execute the I/O operation on the main thread, blocking the event loop until the OS returns the data. This is O(n) in file size, but the constant factor is dominated by disk latency (often 2–10 ms for SSD, 50–150 ms for HDD).

In practice, every synchronous fs call pauses all JavaScript execution, including incoming HTTP requests, timers, and resolved promises. The event loop cannot process any pending callbacks until the file operation completes. For a 1 MB file on a cold disk, readFileSync can stall the loop for 50–100 ms. Under concurrent load, this compounds: if 10 requests each trigger a synchronous read, the total blocking time can exceed 1 second, causing cascading timeouts in upstream load balancers and API gateways.

Use synchronous fs methods only during startup (reading config files, loading certificates) or in CLI scripts where concurrency is irrelevant. Never use them inside request handlers, middleware, or any latency-sensitive path. For production API gateways, always prefer the promise-based fs.promises API with proper error handling, or stream large files with fs.createReadStream to avoid buffering entire payloads in memory.

readFileSync Is Not Just Slow — It's a Concurrency Killer
Even a single synchronous readFileSync call in a request handler can block thousands of concurrent connections, turning a 10 ms disk read into a 10-second queue delay.
Production Insight
A Node.js API gateway serving 500 req/s with a 50 KB config file readFileSync on every request caused p99 latency to spike from 20 ms to 2.3 seconds, triggering a cascading failure in the upstream load balancer.
Symptom: CPU usage remained low (<30%), but event loop lag exceeded 5 seconds, visible via process.hrtime() or event loop monitoring tools.
Rule of thumb: If you use any synchronous fs call in a request handler, your service will fail under moderate load — always use fs.promises or streams for runtime file access.
Key Takeaway
Synchronous fs calls block the event loop — never use them in request handlers.
Use fs.promises for one-shot reads/writes and streams for large payloads.
Startup config loading is the only acceptable use case for readFileSync in a server process.
Node.js fs: readFileSync Blocks Event Loop THECODEFORGE.IO Node.js fs: readFileSync Blocks Event Loop Sync vs async file ops and streaming for API gateways fs.readFileSync Blocks event loop, stalls API gateway fs/promises readFile Non-blocking, returns promise fs.createReadStream Streams large files, low memory fs.access + open Race condition: use open instead Async file operations Keep event loop responsive ⚠ fs.access check is a race condition Use fs.open with O_EXCL or handle ENOENT THECODEFORGE.IO
thecodeforge.io
Node.js fs: readFileSync Blocks Event Loop
Nodejs File System Module

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.

io/thecodeforge/fs/syncVsAsync.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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');
Output
Config loaded synchronously: MyApp
This line prints BEFORE the async callback fires — proof the thread is free
Config loaded asynchronously: MyApp
Sync Blocks, Async Frees — The Analogy That Sticks
  • 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.
Production Insight
readFileSync inside a request handler blocks every other request in the process until the disk read completes — not just slows them down, blocks them completely.
On cloud EBS with baseline IOPS, a 2ms local read becomes 200ms under concurrent load. Multiply that by hundreds of concurrent requests and you have a complete event loop stall.
Rule: sync fs methods belong at startup only. After 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.
Key Takeaway
Sync fs methods block the event loop — one slow read under I/O pressure serializes every concurrent request behind it.
Use sync only at startup before server.listen(). Use fs/promises for everything that runs after.
If you see readFileSync inside a request handler in a code review, reject it — the developer who wrote it may not have seen it cause an outage yet, but you have.
Sync vs Async Decision
IfLoading config, environment validation, or required assets before server.listen() at startup
UsereadFileSync is acceptable — the server is not handling requests yet, and failing immediately on a missing config is the correct behavior. Wrap in try/catch and call process.exit(1) on failure.
IfReading a file inside a request handler, middleware, or any callback that fires after startup
UseUse fs.promises.readFile with await. Never readFileSync. One blocked read under I/O pressure stalls every concurrent request in the process.
IfProcessing a file that may be larger than available RAM, or whose size is user-controlled
UseNeither readFile nor readFileSync — use createReadStream to process chunks without loading the full file into memory. File size controls memory cost with readFile; chunk size controls it with streams.
IfWriting a log entry or appending to a file on every request or at high frequency
UseUse fs.promises.appendFile or a persistent write stream. Never writeFileSync. Disk writes block the event loop for exactly as long as disk reads do.

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

io/thecodeforge/fs/promiseFileLoader.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 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' });
})();
Output
All configs loaded in parallel
DB host: db.prod.internal
Server port: 3000
Dark mode: true
Report saved to: /app/reports/report-1718200000000.json
Always Use path.join with __dirname — Not Relative Paths
Use path.join(__dirname, 'config', 'database.json') instead of './config/database.json'. When Node.js scripts run from a different working directory — which happens constantly in npm scripts, Docker containers, and CI pipelines — relative paths resolve against 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.
Production Insight
Promise.all reads multiple files in parallel — on spinning disks or EBS, three files with Promise.all take the time of the slowest single read, not the sum of all three.
One try/catch covers every file in the Promise.all — far cleaner than three nested callbacks with separate error checks.
Rule: use fs/promises for all new code. The callback API exists for legacy compatibility. If you are writing new code with fs.readFile and a callback, you are choosing a harder path for no benefit.
Key Takeaway
fs/promises with async/await is the modern default — clean error handling, parallel reads with Promise.all, top-to-bottom readability that makes code review substantially easier.
Promise.all reads N files simultaneously, taking the time of the slowest file, not the accumulated sum.
Use path.join(__dirname, 'file') for every path. Relative paths are a deployment environment trap.

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.

io/thecodeforge/fs/streamLargeFile.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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'
  );
})();
Output
Compressed: /app/server.log -> /app/server.log.gz
Lines processed: 4,821,304
Parse errors: 12
Streams Are Not Optional for Large Files — They Are the Only Safe Approach
readFile loads the entire file into a single Buffer in memory before your code sees any of it. A 2 GB file on a server with 512 MB of available RAM causes an immediate out-of-memory crash — the OS kills the process before you have any chance to handle the error. Streams process data in roughly 64 KB chunks with backpressure, keeping memory usage flat regardless of file size. If the file size is unknown or user-controlled, use streams. This is not a performance optimization — it is the difference between a service that runs and one that crashes.
Production Insight
readFile loads the full file into a Buffer — memory cost equals file size. A 2 GB log on a 512 MB server is an immediate OOM crash.
Streams process in chunks with backpressure — memory cost is roughly the highWaterMark (64 KB default) regardless of file size.
Use 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.
Key Takeaway
readFile loads the entire file into memory — streams keep memory flat by processing at most one chunk at a time.
Backpressure is what prevents the reader from overwhelming the writer — pipeline() handles this automatically.
If the file size is unknown or user-controlled, streams are not optional. Use pipeline() from stream/promises rather than bare pipe() for production code.
Choosing a File Reading Strategy
IfSmall, bounded file under 1 MB — config file, JSON schema, email template
UseUse fs.promises.readFile — simple, fast, the full file fits comfortably in memory. Pass 'utf8' as the encoding argument for text files.
IfLarge text file with known structure — CSV, NDJSON log lines, TSV data
UseUse createReadStream piped to a readline.createInterface with for-await-of — processes line by line with constant memory, handles both Unix and Windows line endings with crlfDelay: Infinity.
IfBinary file that needs transformation — compression, encryption, hashing
UseUse pipeline(createReadStream, transformStream, createWriteStream) from stream/promises — chunk-by-chunk processing with automatic error propagation and stream cleanup.
IfUser-uploaded file with unknown or unbounded size
UseUse streams with an explicit size limit — check accumulated bytes against your threshold during streaming and reject early if exceeded. Never buffer the full content before checking size.

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.

io/thecodeforge/fs/watchAndManageDirs.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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);
})();
Output
Build directory cleaned and recreated: /app/dist
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.
fs.watch Is Not Consistent Across Operating Systems
fs.watch behavior differs between macOS (kqueue), Linux (inotify), and Windows (ReadDirectoryChangesW). On macOS, watching a directory fires events on the directory when files inside it change, not on the specific file. Linux inotify has a configurable limit on watched files that monorepos regularly hit. Windows has different event type semantics. If your file watching logic is user-visible or critical to your application's correctness — not just a development-mode hot reload — use the chokidar package. It abstracts all of this and provides a reliable, consistent API across all platforms.
Production Insight
fs.watch fires two to three events per save due to atomic write patterns — without debouncing, your callback runs multiple times for a single logical change.
Unclosed watchers prevent the event loop from exiting, which causes test suites to hang and process manager shutdowns to stall.
Rule: always debounce fs.watch callbacks at 100ms or more. Always close watchers in SIGINT and SIGTERM handlers. For production-grade watching, use chokidar.
Key Takeaway
fs.watch fires multiple events per save — always debounce with setTimeout to consolidate them into a single callback invocation.
mkdir with recursive: true and rm with recursive: true, force: true are the safe cross-platform equivalents of mkdir -p and rm -rf.
Close watchers in shutdown handlers — open watchers prevent clean process exit and cause test suite hangs.

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.

io/thecodeforge/fs/errorHandling.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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)
  );
})();
Output
Cross-platform safe path: /app/data/report.csv
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
Switch on err.code, Not err.message — This Is Not Negotiable
Error messages in Node.js change between versions and differ between operating systems. The same permission error produces a different message string on macOS versus Linux. The error code — ENOENT, EACCES, EBUSY, EISDIR — is stable across versions and platforms. Always build error handling logic around err.code. String matching on err.message is a maintenance hazard that breaks silently when Node.js updates.
Production Insight
In Docker, EACCES happens when the container user (e.g., node at UID 1000) tries to read files created by root during the image build. Fix it with a chown instruction in the Dockerfile: RUN chown -R node:node /app/data after the COPY that brings those files in.
ENOENT during a writeFile is almost always a missing parent directory, not a missing file — mkdir with recursive: true before writeFile is the canonical fix.
Window users who run the same Node.js code on Linux in CI will encounter path separator bugs if they built paths with string concatenation. Use path.join everywhere.
Key Takeaway
ENOENT is missing path, EACCES is permission denied, EBUSY is file locked — each requires a different response and a different fix.
In Docker, EACCES usually means a UID mismatch between the user that created files and the user running Node — fix with chown in the Dockerfile.
Use path.join for every path, and switch on err.code rather than err.message — both practices make your code work correctly across environments without modification.

fs.readFile vs fs.createReadStream: Don't Load Entire Files Into Memory

Most beginners reach for fs.readFile out of habit. It’s simple: give it a path, get the data. But readFile loads the entire file into memory before returning. That’s fine for 1KB config files. For a 500MB log dump? You just crashed your event loop and exhausted your heap. fs.createReadStream pipes data chunk by chunk using streams. It never buffers the whole file. Your memory stays flat regardless of file size. This isn’t a micro-optimization. It’s the difference between a server that scales and one that OOMs under load. Streams also integrate with pipe() for transforming or writing directly to HTTP responses. Use readFile only when you know the file size is bounded (like package.json). For everything else, stream it. Your future self will thank you when a rogue log file doesn’t bring down production.

stream-vs-readfile.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const fs = require('fs');
const { pipeline } = require('stream');
const http = require('http');

// Bad: loads entire file into memory
http.createServer((req, res) => {
  fs.readFile('/var/log/app.log', (err, data) => {
    if (err) return res.end('error');
    res.end(data);
  });
}).listen(3000);

// Good: streams file response
http.createServer((req, res) => {
  const readStream = fs.createReadStream('/var/log/app.log');
  readStream.on('error', () => res.statusCode = 500).pipe(res);
}).listen(3001);

console.log('Servers running on 3000 (bad) and 3001 (good)');
Output
Servers running on 3000 (bad) and 3001 (good)
# Under 500MB concurrent load, 3000 crashes with 'FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed'
# 3001 handles unlimited concurrent connections smoothly
Production Trap:
readFile with large files kills container memory limits. Gunicorn + Node? Forget it. Always bound memory per request with streams or use highWaterMark to control chunk size (default 64KB).
Key Takeaway
If you can't fit the file in a single tweet, don't load it with readFile — stream it or regret it.

The fs.access Check Is a Race Condition — Use open Instead

I see this pattern everywhere: check if a file exists with fs.access, then do something. That’s two round trips to the kernel. Worse, the file can be deleted or permissions changed between the check and the operation. This is a classic TOCTOU (time-of-check, time-of-use) bug. The fix? Just try the operation and catch the error. Use fs.open with appropriate flags ('r' for read, 'w' for write), which atomically checks permissions and opens the file. One system call. No window for races. This matters in concurrent environments like API servers or cron jobs processing shared files. fs.access has legitimate uses — like testing permissions before a batch — but never as a guard for subsequent I/O. In 2024, Node also exposes fs.stat for metadata, but same race applies. The golden rule: don’t check, just do, and handle failure gracefully.

no-toctou.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const fs = require('fs/promises');

// BAD: race condition waiting to happen
async function badRead(path) {
  try {
    await fs.access(path, fs.constants.R_OK);
    return await fs.readFile(path, 'utf-8');  // File could vanish here
  } catch {
    return null;
  }
}

// GOOD: open atomically, no check needed
async function goodRead(path) {
  let fd;
  try {
    fd = await fs.open(path, 'r');
    return await fd.readFile('utf-8');
  } catch {
    return null;
  } finally {
    if (fd) await fd.close();
  }
}

console.log(await goodRead('/tmp/config.json'));
Output
{
"db_host": "localhost",
"port": 5432
}
# Even if another process deletes /tmp/config.json between open and read, this still works because fd holds the inode
Pro Tip:
Use fs.open with the 'wx' flag for exclusive creation — it fails if the file exists, avoiding race conditions in temp file generation or lock files.
Key Takeaway
Never check permissions before an operation; the only safe race is the one you don't run.
● Production incidentPOST-MORTEMseverity: high

readFileSync in a Request Handler Took Down the API Gateway

Symptom
P99 latency on the API gateway spiked from 45ms to 12 seconds during a traffic surge. Load balancer health checks started failing. The gateway appeared to be processing requests — no errors, no crashes, the process was alive — but responses took so long that upstream services timed out and retried, amplifying the load by roughly 3x. Within four minutes, all three gateway nodes were effectively unresponsive. The cascade went: slow responses triggered retries, retries increased load, increased load made the blocking worse, worse blocking triggered more timeouts.
Assumption
The on-call engineer assumed database connection pool exhaustion — a common cause of latency spikes that looks almost identical in dashboards. They spent 20 minutes checking database metrics, connection counts, query times, and slow query logs. Everything looked completely normal. The database was responding in under 5ms throughout the incident.
Root cause
A middleware function that loaded a feature-flag JSON file used fs.readFileSync instead of an async alternative. The file was 2.3 MB and lived on an EBS volume with baseline IOPS provisioned. Under normal traffic at 50 requests per second, the synchronous read took around 8ms per request — noticeable if you looked for it, but not obviously catastrophic. During the traffic surge at 400 requests per second, hundreds of concurrent readFileSync calls competed for the same EBS I/O budget. EBS throttled the IOPS, each read ballooned to 200 to 800ms, and because readFileSync blocks the event loop unconditionally, every other request in the Node.js process queued behind whichever disk read was currently in progress. The event loop was effectively stalled for seconds at a time. From the outside, the server appeared alive. From the inside, it was processing one thing at a time.
Fix
Replaced readFileSync with fs.promises.readFile. Added an in-memory cache with a 30-second TTL so the file is re-read from disk only when the cache expires, not on every request. The cache hit path adds zero I/O overhead — it returns the parsed JSON directly from a Map lookup. Added event loop lag monitoring via perf_hooks.monitorEventLoopDelay to the /health endpoint, so future event loop stalls become visible in dashboards before they cause outages. The combination of async reads and caching reduced the I/O cost of feature flag loading from 400 disk reads per second to roughly 1 disk read every 30 seconds.
Key lesson
  • Never use synchronous fs methods inside request handlers, middleware, or any code path that runs after 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 issues5 entries
Symptom · 01
Event loop lag exceeds 100ms under load
Fix
Something is blocking the event loop — and in a Node.js API server, synchronous fs calls are the most common cause. Search your codebase for all synchronous fs operations: grep -rn 'Sync(' src/ | grep -E 'readFileSync|writeFileSync|statSync|readdirSync|appendFileSync|existsSync'. If any of these appear inside request handlers, middleware functions, periodic timers, or Worker-thread-unaware code paths, replace them with their async equivalents from fs/promises. Add monitorEventLoopDelay from perf_hooks to your /health endpoint to get a continuous lag measurement in dashboards — do this before the next incident, not during one.
Symptom · 02
ENOENT errors in production but not in development
Fix
The file path is resolving relative to 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.
Symptom · 03
Process RSS grows steadily when processing log files or large data files
Fix
You are reading large files with readFile or readFileSync, which load the entire file into a Buffer in memory. For a 500 MB log file, that is 500 MB of RAM consumed per concurrent read — and in a server handling multiple requests, those Buffers accumulate. Switch to createReadStream with readline for line-by-line text processing, or pipe through a Transform stream for binary processing. Check current memory pressure with process.memoryUsage().rss to understand how much native memory the process is holding before and after switching to streams.
Symptom · 04
fs.watch fires multiple events for a single file save
Fix
Text editors and most write operations perform atomic saves — write to a temporary file, then rename it over the original. This produces at least two events: a 'rename' event and a 'change' event, sometimes more. Implement a debounce: store a setTimeout reference and clear it on each event, only executing the callback after 100ms of silence following the last event. For production-grade file watching where reliability across operating systems matters, the chokidar package normalizes all of this and provides a dramatically more predictable API with significantly fewer edge cases.
Symptom · 05
EACCES permission denied errors after deployment
Fix
The Node.js process user does not have read or write permissions on the target file or directory. Run ls -la on the failing path to check ownership. In Docker, files copied into the image or mounted as volumes often retain the ownership of the host user that created them, which may not match the non-root USER specified in the Dockerfile. Fix ownership with chown in the Dockerfile after the COPY instruction, or adjust the USER directive to match the file ownership. In production hosts, check that your process user is part of the correct group for the target directory.
★ fs Module Quick DebugFast symptom-to-action reference for file system issues in production Node.js services.
Event loop stalled — requests queuing
Immediate action
Find synchronous fs calls blocking the event loop — they are almost always the cause
Commands
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)"
Fix now
Replace all Sync calls inside request handlers with fs/promises equivalents wrapped in async functions. For frequently-read files like config or feature flags, add an in-memory cache with a 30-second TTL to eliminate redundant disk reads entirely.
ENOENT: no such file or directory+
Immediate action
Verify the path resolves correctly from the actual process working directory, not from where you expect it to be
Commands
node -e "console.log('cwd:', process.cwd()); console.log('__dirname:', __dirname)"
ls -la <the-failing-path>
Fix now
Replace all relative paths with path.join(__dirname, 'filename'). In Docker, verify that WORKDIR in the Dockerfile matches where your application expects to find its files. Never trust that cwd equals the script directory — they frequently differ in CI and containerized deployments.
Process out of memory reading a large file+
Immediate action
Check file size against available memory and confirm readFile is being used where a stream is needed
Commands
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'})"
Fix now
Replace readFile with createReadStream piped through a Transform or processed with readline. The memory cost of createReadStream is roughly the highWaterMark chunk size (64 KB by default) regardless of how large the file is.
EACCES: permission denied on file operation+
Immediate action
Check file ownership against process user — this is almost always a Docker or deployment environment mismatch
Commands
ls -la <the-failing-path>
node -e "console.log('uid:', process.getuid && process.getuid(), 'gid:', process.getgid && process.getgid())"
Fix now
Fix ownership in the Dockerfile with a chown instruction after COPY, or adjust the USER directive to match who owns the files. On production hosts, ensure your systemd service or process manager runs the Node process under the correct user.
fs API Styles Compared
AspectCallback API (fs.readFile)Promise API (fs/promises)Stream API (createReadStream)
Syntax styleError-first callback — (err, data) => {}async/await with try/catchEvent-driven with pipe() or pipeline() — or for-await-of with readline
Error handlingManual check on first argument — if (err) return. Easy to forget, nests awkwardlyStandard try/catch with switch on err.code — one handler covers all operations in the blockError event listeners on each stream — or use pipeline() which propagates errors automatically
Memory usageLoads entire file into a single Buffer — memory cost equals file sizeLoads entire file into a single Buffer — same memory footprint as callback APIProcesses in ~64KB chunks — memory stays constant regardless of file size
Parallel operationsRequires async.parallel, counters, or manual coordination across callbacksClean Promise.all([...]) — parallel reads in one line, rejects on first failureMultiple streams run concurrently by default — each manages its own flow independently
Code readabilityNests into callback hell for multi-step operations — each step adds a level of indentationReads top-to-bottom like synchronous code — easiest to code-review and maintainModerate — pipe chains are concise, but error listener wiring is verbose without pipeline()
Node.js versionAvailable since Node 0.x — works everywhereStable since Node 10 (v10.0.0) — the default choice for all code written after 2018Available since Node 0.x — stable API with ongoing performance improvements
When to useLegacy codebases only — or when integrating with APIs that expect callback styleAll new code for files with bounded, known sizes — config, JSON, templates, schemasLarge files, unknown sizes, user uploads, binary transforms, or any pipeline that benefits from backpressure
Stack tracesShallow — context is lost across callback boundaries, making stack traces hard to followFull async stack traces in Node 12+ — much easier to trace errors back to their originStream error stack traces can be fragmented — pipeline() from stream/promises helps consolidate them

Key takeaways

1
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.
2
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.
3
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.
4
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.
5
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.
6
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

5 patterns
×

Using readFileSync inside an HTTP request handler

Symptom
Under load, response times spike and requests queue behind each other. Event loop lag exceeds 100ms. The server appears to be processing requests — no errors, no crashes — but P99 latency climbs to seconds while the event loop is stalled on disk I/O. On throttled cloud storage, a single readFileSync can block for 200ms or more, serializing every concurrent request behind it for that entire duration.
Fix
Move all file reads inside request handlers to fs.promises.readFile with await. If the file changes infrequently — feature flags, config, static data — cache the parsed result in memory with a TTL check using Date.now(). The cache hit path returns the in-memory value with zero I/O. Add monitorEventLoopDelay from perf_hooks to your health endpoint so future event loop stalls are visible in dashboards before they become outages.
×

Not handling the ENOENT error code specifically

Symptom
The app crashes with an unhandled Promise rejection showing 'no such file or directory', losing all useful context. Or the opposite: a generic catch block silently handles both a missing config file (ENOENT, often recoverable) and a permission error (EACCES, requires infrastructure fix) identically — masking problems that need different responses.
Fix
Always switch on err.code. ENOENT means the file or directory does not exist — often recoverable with a default value, a creation step, or a clear startup error. EACCES means the process user cannot access the file — requires an infrastructure fix, not application code. EBUSY means file lock — retry after delay. Each code has a distinct cause and a distinct correct response.
×

Forgetting the encoding argument when reading text files

Symptom
fs.readFile returns a Buffer object instead of a string. JSON.parse fails with 'SyntaxError: Unexpected token' because it received a Buffer. String operations like split, trim, and includes return unexpected results or throw. The code looks correct — readFile is awaited, the path is right — but the result is raw binary, not text.
Fix
Pass 'utf8' as the encoding argument: readFile(filePath, 'utf8'). Without the encoding argument, readFile returns a Buffer by design — this is correct behavior for binary files like images, audio, and executables. For text files, the encoding is optional in the API signature but mandatory for your code to work correctly. If you forget it, call buffer.toString('utf8') before processing.
×

Using relative paths instead of __dirname-based paths

Symptom
Code works when run from the project root with node src/server.js but fails with ENOENT when run via npm scripts, from a Docker container with a different WORKDIR, from a CI pipeline that sets a different working directory, or from any location that is not the project root. The path resolves against process.cwd() rather than the script's location.
Fix
Use path.join(__dirname, 'config', 'database.json') for every file path in production code. __dirname resolves to the directory containing the current file, regardless of where the Node.js process was launched from. Never use './filename' or string-concatenated paths in code that needs to work reliably across environments.
×

Not closing fs.watch watchers on process shutdown

Symptom
In test suites, the Node.js process hangs after all tests complete because an open fs.watch handle keeps the event loop alive. In production, file watching started per-connection or per-request accumulates open handles without any cleanup, eventually exhausting the available inotify watchers on Linux (error: ENOSPC from inotify) or producing unexpected behavior.
Fix
Store the watcher reference returned by fs.watch() and call watcher.close() in your process.on('SIGINT') and process.on('SIGTERM') handlers. In test suites, close watchers in afterEach or afterAll hooks. Create watchers once at application startup and close them once during graceful shutdown — never inside per-request or per-connection handlers.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between fs.readFile and fs.createReadStream, and w...
Q02SENIOR
If fs.watch fires multiple events for a single file save, how would you ...
Q03JUNIOR
How would you read a 5GB CSV file in Node.js without causing an out-of-m...
Q04SENIOR
Explain the difference between ENOENT, EACCES, and EBUSY error codes in ...
Q01 of 04SENIOR

What's the difference between fs.readFile and fs.createReadStream, and when would you choose one over the other?

ANSWER
fs.readFile loads the entire file into memory as a Buffer before invoking the callback or resolving the Promise. It is simple and appropriate for small, bounded files — config files, JSON schemas, templates — where the content fits comfortably in memory and you need the full content before doing anything with it. fs.createReadStream reads the file in chunks — 64 KB by default — and emits data as each chunk arrives. Memory usage stays constant at roughly the chunk size regardless of file size. This is the correct approach when the file size is unknown, user-controlled, or potentially large. The practical dividing line is roughly 1 MB. Under that, readFile is simpler and the memory cost is acceptable. Over that — log files, CSVs, data exports, user uploads — use createReadStream. For text files, combine createReadStream with readline.createInterface and for-await-of to process the file line by line. For binary transforms like compression or encryption, pipe createReadStream through a Transform stream to createWriteStream using pipeline() from stream/promises — which also handles error propagation and stream cleanup that bare pipe() does not.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the Node.js fs module used for?
02
Should I use fs.readFile or fs/promises readFile in 2026?
03
Why does fs.readFile return a Buffer instead of a string?
04
When should I use streams instead of readFile?
05
How do I handle file paths correctly in cross-platform Node.js applications?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Node.js. Mark it forged?

11 min read · try the examples if you haven't

Previous
npm and package.json Explained
11 / 18 · Node.js
Next
Socket.io and WebSockets