| |

Node.js 16 🟢 Synchronous vs Asynchronous vs Promise-based File Operations

The node:fs module provides three ways to read a file. The synchronous form returns the contents directly and blocks the thread until the read completes. The callback form starts the read, returns immediately, and invokes a function when the data is ready. The promise form wraps the callback in a Promise and works with async/await. All three eventually do the same thing — read bytes from disk — but they differ in how the thread is used, how errors are handled, and what the calling code looks like. Choosing the right form is the difference between a server that serves thousands of requests and one that stalls on a single slow read.

The distinction matters because Node.js runs JavaScript on a single thread. A synchronous read blocks that thread, so no other JavaScript executes while the read is in progress. For a script that reads one configuration file at startup, that is fine. For a request handler that reads a file on every request, it is a denial of service waiting to happen. The asynchronous forms delegate the blocking work to the libuv thread pool, and the event loop continues to process other work until the read completes.

This chapter covers the three API forms, the blocking behavior of each, the error handling patterns, the performance characteristics, the rules for choosing between them, and the patterns that keep the event loop responsive.

Key point: Use the promise API (node:fs/promises) with async/await for all application code. Use the synchronous API only at startup and in scripts, where blocking does not matter. Use the callback API when integrating with legacy code or when the error-first convention is required. All three forms are equivalent in what they do; they differ in how they use the thread and how they handle errors.


Why three API forms exist

The blocking problem. The synchronous API is the simplest to read. readFileSync returns the contents, and the next line uses them. But it blocks the event loop until the read completes. If the file is on a slow disk, the entire process is idle but unavailable. For a server, that is unacceptable.

The callback problem. The callback API was the original asynchronous form. It uses the error-first convention: the callback receives an error as the first argument and the result as the second. It works, but the nesting and the repeated error checks make the code harder to read as the number of operations grows.

The promise problem. The promise API wraps the callback in a Promise, which works with async/await. The result reads like synchronous code, the error handling is a single try/catch, and the code composes with Promise.all and other promise utilities. This is the recommended API for application code.

The thread pool problem. File operations do not have a portable non-blocking OS primitive, so libuv runs them on the thread pool. The default pool size is four threads. A synchronous read does not use the pool; it blocks the event loop directly. An asynchronous read uses the pool, and the event loop continues.

The use-case problem. The three forms map to three use cases. A script or a startup routine uses the synchronous form because it runs before the server is serving requests and the simplicity is worth the blocking. A request handler uses the promise form because the event loop must stay responsive. A library that must support older Node.js versions uses the callback form because the promise API is newer.


a. The synchronous API

The synchronous methods end in Sync. They return the result directly and throw on error.

const fs = require('node:fs');

const data = fs.readFileSync('file.txt', 'utf8');
console.log(data);

The readFileSync call blocks the thread until the file is read. The next line runs only after the data is available. If the file does not exist, the call throws an Error with a code property like ENOENT.

Other synchronous methods:

fs.writeFileSync('output.txt', 'content');
fs.appendFileSync('log.txt', 'line\n');
fs.mkdirSync('dir', { recursive: true });
fs.readdirSync('.');
fs.statSync('file.txt');
fs.unlinkSync('file.txt');

The synchronous API is appropriate when:

SituationWhy
Startup configurationThe server is not serving requests yet
Command-line scriptsBlocking does not affect other work
Build toolsThe tool’s job is to complete the task
TestsSimplicity is worth more than concurrency

The synchronous API is inappropriate in a request handler, in an event listener, or anywhere the code runs while the server is serving requests.


b. The callback API

The callback API takes a function as the last argument. The function receives an error as the first argument and the result as the second.

const fs = require('node:fs');

fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});

The readFile call returns immediately. The callback runs when the read completes. The error-first convention requires the callback to check err before using data.

Other callback methods follow the same pattern:

fs.writeFile('output.txt', 'content', (err) => {
  if (err) throw err;
});

fs.mkdir('dir', { recursive: true }, (err) => {
  if (err) throw err;
});

The callback API is what the promise API wraps. It is still used in legacy code and in libraries that predate the promise API. For new code, the promise API is preferred.

The nesting problem appears when sequential operations are needed:

fs.readFile('a.txt', 'utf8', (err, a) => {
  if (err) return console.error(err);
  fs.readFile('b.txt', 'utf8', (err, b) => {
    if (err) return console.error(err);
    fs.writeFile('c.txt', a + b, (err) => {
      if (err) return console.error(err);
      console.log('done');
    });
  });
});

Each step nests one level deeper, and the error check repeats at every level. This is the structure that motivated the promise API.


c. The promise API

The promise API lives in node:fs/promises. It returns Promise objects and works with async/await.

const { readFile } = require('node:fs/promises');

async function main() {
  try {
    const data = await readFile('file.txt', 'utf8');
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

main();

The readFile call returns a promise. The await suspends the async function until the promise settles, and the event loop continues processing other work. On success, data holds the contents. On failure, the catch block receives the error.

Other promise methods:

const { writeFile, appendFile, mkdir, readdir, stat, unlink } = require('node:fs/promises');

await writeFile('output.txt', 'content');
await appendFile('log.txt', 'line\n');
await mkdir('dir', { recursive: true });
const files = await readdir('.');
const stats = await stat('file.txt');
await unlink('file.txt');

The promise API is the recommended form for application code. The async/await syntax reads like synchronous code, the error handling is a single try/catch, and the operations compose with Promise.all and other promise utilities.

Sequential operations do not nest:

async function combine() {
  const a = await readFile('a.txt', 'utf8');
  const b = await readFile('b.txt', 'utf8');
  await writeFile('c.txt', a + b);
}

Each line is a step, and the error handling is a single try/catch around the whole function.


d. Error handling in each form

The three forms handle errors differently.

Synchronous errors are thrown. The caller uses try/catch or lets the error propagate.

try {
  const data = fs.readFileSync('file.txt', 'utf8');
} catch (err) {
  console.error('Read failed:', err.message);
}

Callback errors are passed to the callback as the first argument. The callback checks err before using the result.

fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Read failed:', err.message);
    return;
  }
  console.log(data);
});

Promise errors become rejections. The caller uses try/catch with await or .catch() on the promise.

try {
  const data = await readFile('file.txt', 'utf8');
} catch (err) {
  console.error('Read failed:', err.message);
}

The Error object has a code property that identifies the kind of failure:

CodeMeaning
ENOENTFile or directory does not exist
EACCESPermission denied
EISDIRExpected a file, found a directory
ENOTDIRExpected a directory, found a file
EEXISTFile already exists
EMFILEToo many open files
try {
  const data = await readFile('file.txt', 'utf8');
} catch (err) {
  if (err.code === 'ENOENT') {
    console.log('File not found');
  } else {
    throw err;
  }
}

The code check is the way to distinguish “the file is missing, which is expected” from “something else went wrong.”


e. Performance characteristics

The three forms have different performance profiles.

FormBlocks event loopUses thread poolLatency
SynchronousYesNoSame as the read
CallbackNoYesSame as the read
PromiseNoYesSame as the read

The synchronous form blocks the event loop for the duration of the read. If the read takes 50 milliseconds, the entire process is idle for 50 milliseconds, and no other JavaScript runs.

The asynchronous forms (callback and promise) delegate the blocking read to the thread pool. The event loop continues processing other work. When the read completes, the callback or the promise resolution runs on the event loop.

The thread pool has four threads by default. If more than four file operations are in progress at once, the additional operations queue. For a server that reads many files concurrently, increasing UV_THREADPOOL_SIZE may help.

UV_THREADPOOL_SIZE=16 node server.js

The promise API adds a small overhead for the promise machinery compared to the callback API, but the difference is negligible compared to the cost of the file operation itself.

The rule is simple: use the synchronous form only when blocking does not matter. In any code that runs while the server is serving requests, use the promise form.


f. Choosing the right form

The choice depends on where the code runs and what it needs to do.

SituationRecommended form
Startup configurationSynchronous
Command-line scriptSynchronous
Build toolSynchronous
Test setupSynchronous
Request handlerPromise
Event listenerPromise
Concurrent file operationsPromise with Promise.all
Legacy library integrationCallback
Library supporting old Node.jsCallback

The synchronous form is correct in code that runs before the server starts serving requests. The blocking is harmless because there is nothing else to do.

The promise form is correct in code that runs while the server is serving requests. The blocking is delegated to the thread pool, and the event loop stays responsive.

The callback form is correct when the code must support older Node.js versions or when integrating with a library that uses the callback convention. For new code, the promise form is preferred.

A common pattern is to use the synchronous form at startup and the promise form everywhere else:

// startup.js — synchronous is fine
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));

// request.js — promise is required
app.get('/data', async (req, res) => {
  try {
    const data = await readFile('data.json', 'utf8');
    res.send(data);
  } catch (err) {
    res.status(500).send(err.message);
  }
});

The two forms coexist in the same project. The choice is made per call site, based on whether the code blocks the event loop at a time when it matters.


Complete Example Session

// ============================================
// PART 1: SYNCHRONOUS READ
// ============================================
const fs = require('node:fs');

const data = fs.readFileSync('file.txt', 'utf8');
console.log(data);
// ============================================
// PART 2: SYNCHRONOUS ERROR HANDLING
// ============================================
try {
  const data = fs.readFileSync('file.txt', 'utf8');
  console.log(data);
} catch (err) {
  console.error('Read failed:', err.message);
}
// ============================================
// PART 3: CALLBACK READ
// ============================================
fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});
// ============================================
// PART 4: CALLBACK NESTING
// ============================================
fs.readFile('a.txt', 'utf8', (err, a) => {
  if (err) return console.error(err);
  fs.readFile('b.txt', 'utf8', (err, b) => {
    if (err) return console.error(err);
    fs.writeFile('c.txt', a + b, (err) => {
      if (err) return console.error(err);
      console.log('done');
    });
  });
});
// ============================================
// PART 5: PROMISE READ
// ============================================
const { readFile } = require('node:fs/promises');

async function main() {
  const data = await readFile('file.txt', 'utf8');
  console.log(data);
}

main();
// ============================================
// PART 6: PROMISE ERROR HANDLING
// ============================================
try {
  const data = await readFile('file.txt', 'utf8');
  console.log(data);
} catch (err) {
  if (err.code === 'ENOENT') {
    console.log('File not found');
  } else {
    throw err;
  }
}
// ============================================
// PART 7: SEQUENTIAL PROMISE OPERATIONS
// ============================================
async function combine() {
  const a = await readFile('a.txt', 'utf8');
  const b = await readFile('b.txt', 'utf8');
  await writeFile('c.txt', a + b);
}
// ============================================
// PART 8: CONCURRENT PROMISE OPERATIONS
// ============================================
const [a, b, c] = await Promise.all([
  readFile('a.txt', 'utf8'),
  readFile('b.txt', 'utf8'),
  readFile('c.txt', 'utf8'),
]);
// ============================================
// PART 9: STARTUP SYNCHRONOUS, RUNTIME PROMISE
// ============================================
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));

app.get('/data', async (req, res) => {
  try {
    const data = await readFile('data.json', 'utf8');
    res.send(data);
  } catch (err) {
    res.status(500).send(err.message);
  }
});
// ============================================
// PART 10: INCREASE THREAD POOL SIZE
// ============================================
// UV_THREADPOOL_SIZE=16 node server.js
console.log(process.env.UV_THREADPOOL_SIZE || 'default: 4');

These ten parts cover the synchronous read, synchronous error handling, the callback read, callback nesting, the promise read, promise error handling, sequential promise operations, concurrent promise operations, the startup-synchronous pattern, and increasing the thread pool size.


Quick Reference

The Three Forms

FormImportBlockingError
Synchronousnode:fsYesThrows
Callbacknode:fsNoError-first argument
Promisenode:fs/promisesNoRejection

Method Names

OperationSyncCallbackPromise
ReadreadFileSyncreadFilereadFile
WritewriteFileSyncwriteFilewriteFile
AppendappendFileSyncappendFileappendFile
MkdirmkdirSyncmkdirmkdir
ReaddirreaddirSyncreaddirreaddir
StatstatSyncstatstat
UnlinkunlinkSyncunlinkunlink

Error Codes

CodeMeaning
ENOENTFile not found
EACCESPermission denied
EISDIRIs a directory
ENOTDIRNot a directory
EEXISTAlready exists
EMFILEToo many open files

When to Use Each

SituationForm
StartupSynchronous
ScriptSynchronous
Request handlerPromise
Event listenerPromise
Legacy codeCallback
Concurrent operationsPromise with Promise.all

Performance

FormEvent loopThread pool
SynchronousBlockedNot used
CallbackFreeUsed
PromiseFreeUsed

Best Practices

✅ Do This:

// Use the promise API in request handlers
const { readFile } = require('node:fs/promises');
const data = await readFile('file.txt', 'utf8');

// Use the synchronous API at startup
const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));

// Handle errors with the code property
try { await readFile('file.txt', 'utf8'); }
catch (err) { if (err.code === 'ENOENT') { } }

// Run concurrent operations with Promise.all
const [a, b] = await Promise.all([readFile('a'), readFile('b')]);

// Increase the thread pool for file-heavy workloads
// UV_THREADPOOL_SIZE=16 node server.js

❌ Don’t Do This:

// Use the synchronous API in a request handler
app.get('/', (req, res) => {
  const data = fs.readFileSync('file.txt');  // ❌ blocks
});

// Nest callbacks for sequential operations
fs.readFile('a', (err, a) => {
  fs.readFile('b', (err, b) => {  // ❌ use promises
    fs.writeFile('c', a + b, () => { });
  });
});

// Ignore the error
fs.readFile('file.txt', 'utf8', (err, data) => {
  console.log(data);  // ❌ err ignored
});

// Use the callback API for new code
fs.readFile('file.txt', 'utf8', (err, data) => { });  // ❌ prefer promises

Common Pitfalls

PitfallWhy It HappensFix
Server unresponsiveSynchronous read in a handlerUse the promise API
ENOENT crashNo error handlingUse try/catch
Callback hellSequential callbacksUse async/await
Thread pool saturationMore than 4 concurrent readsIncrease UV_THREADPOOL_SIZE
EACCES errorFile permissionsCheck permissions
Data undefinedError used the resultCheck err first

Real-World Examples

1. Startup Config

const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));

2. Request Handler

app.get('/data', async (req, res) => {
  const data = await readFile('data.json', 'utf8');
  res.send(data);
});

3. CLI Script

const input = fs.readFileSync(process.argv[2], 'utf8');
console.log(input.toUpperCase());

4. Concurrent Reads

const [a, b, c] = await Promise.all([
  readFile('a.txt', 'utf8'),
  readFile('b.txt', 'utf8'),
  readFile('c.txt', 'utf8'),
]);

5. Write and Append

await writeFile('output.txt', 'content');
await appendFile('log.txt', 'line\n');

6. Missing File Fallback

try {
  return await readFile('cache.json', 'utf8');
} catch (err) {
  if (err.code === 'ENOENT') return null;
  throw err;
}

7. Recursive Mkdir

await mkdir('a/b/c', { recursive: true });

8. List Directory

const files = await readdir('.');

9. File Stats

const stats = await stat('file.txt');
if (stats.isFile()) { }

10. Delete File

await unlink('file.txt');

Visual

The Three Forms

┌──────────────────────────────────────────────────────────────┐
│  SYNCHRONOUS:                                                │
│  const data = readFileSync('file.txt');                      │
│  └── Blocks the event loop until the read completes          │
│                                                              │
│  CALLBACK:                                                   │
│  readFile('file.txt', (err, data) => { });                   │
│  └── Returns immediately, callback runs later                │
│                                                              │
│  PROMISE:                                                    │
│  const data = await readFile('file.txt');                    │
│  └── Returns immediately, await suspends the async function  │
└──────────────────────────────────────────────────────────────┘

Event Loop and Thread Pool

┌──────────────────────────────────────────────────────────────┐
│  SYNCHRONOUS:                                                │
│  Event loop: [────── read ──────][other work]                │
│  └── The read blocks everything                              │
│                                                              │
│  ASYNCHRONOUS:                                               │
│  Event loop: [read]─[other work]─[other work]─[callback]     │
│  Thread pool: [───── read ─────]                             │
│  └── The read runs on a worker thread                        │
└──────────────────────────────────────────────────────────────┘

Error Handling Comparison

┌──────────────────────────────────────────────────────────────┐
│  SYNCHRONOUS:                                                │
│  try { readFileSync('file.txt'); }                           │
│  catch (err) { }                                             │
│                                                              │
│  CALLBACK:                                                   │
│  readFile('file.txt', (err, data) => {                       │
│    if (err) { }                                              │
│  });                                                         │
│                                                              │
│  PROMISE:                                                    │
│  try { await readFile('file.txt'); }                         │
│  catch (err) { }                                             │
└──────────────────────────────────────────────────────────────┘

Choosing a Form

┌──────────────────────────────────────────────────────────────┐
│  Does the code run while the server is serving requests?     │
│       │                                                      │
│       ├── No ──▶ Synchronous is fine                         │
│       │          (startup, scripts, tests)                   │
│       │                                                      │
│       └── Yes ──▶ Use the promise API                        │
│                   (request handlers, event listeners)        │
│                                                              │
│  Integrating with legacy callback code?                      │
│  └── Use the callback API or promisify                       │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
SynchronousreadFileSync, blocks, throws
CallbackreadFile(path, cb), non-blocking, error-first
PromisereadFile(path), non-blocking, rejects
Promise importnode:fs/promises
Default thread pool4
Pool configurationUV_THREADPOOL_SIZE
StartupSynchronous is fine
RuntimePromise is required
Error codesENOENT, EACCES, EISDIR
Concurrent readsPromise.all

Key takeaways:

  • The three forms do the same thing but differ in how they use the thread. The synchronous form blocks the event loop. The asynchronous forms delegate the blocking work to the libuv thread pool and keep the event loop free.
  • Use the promise API for application code. It works with async/await, has a single error channel, and composes with Promise.all.
  • Use the synchronous API only at startup and in scripts. The blocking is harmless when nothing else is running.
  • Use the callback API for legacy code. It is the original asynchronous form, and the promise API wraps it.
  • The thread pool has four threads by default. More than four concurrent file operations queue. Increase UV_THREADPOOL_SIZE for file-heavy workloads.
  • Error handling differs by form. Synchronous throws, callback receives the error as the first argument, and promise rejects.
  • The code property identifies the failure. ENOENT means the file is missing, EACCES means permission denied, and so on.

Remember: The choice between synchronous, callback, and promise file operations is a choice about how the thread is used. The synchronous form blocks the event loop, which is fine at startup and in scripts but unacceptable in a request handler. The asynchronous forms delegate the blocking read to the thread pool and keep the event loop responsive. The promise form is the recommended API for new code because it reads like synchronous code and has a single error channel. The callback form is still present in legacy code and libraries. Choose the form based on where the code runs, and use the promise API everywhere the event loop must stay responsive.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!