Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Updated
4 min readView as Markdown

Why async code exists?

Why does async code exist in nodejs? Because we're humans and everything we develop is but a reflection of our values and workings.

Computers are asynchronous by design.

Asynchronous means that things can happen independently of the main program flow.

The above code definition is by nodejs docs. Unlike multi-threaded runtime environments that handle concurrent tasks by spawning new threads, Node.js runs on a single-threaded Event Loop.

If Node.js executed code synchronously—meaning line by line, waiting for every database query or file read to finish—the entire server would freeze whenever an input/output (I/O) operation occurred. Asynchronous architecture exists to keep the main thread free, allowing Node.js to process thousands of incoming requests simultaneously without getting blocked.

File Reading

File reading is a pretty fundamental and reused function of any software, but the way to do it is pretty resource exhaustive. Also, time required to

  • Fetch a file

  • Read from it

  • Extract data from it

  • Manipulate file structure or data

are pretty time consuming tasks. Hence if any software be it web, mobile or other some other form would stop all its operation when encountering a file, that would be catastrophic for the service and its workings.

Hence, our asynchronous nature of nodejs allowed us to perform some tasks which require extra resources parallely while other code blocks executed.

To stay fast and handle thousands of concurrent requests, Node.js uses asynchronous, non-blocking I/O. Instead of freezing the thread during disk/network tasks, Node offloads the heavy lifting to system threads and continues running other code.

Callback-Based Async Execution

A callback is simply a function passed as an argument to another function, which gets executed once an asynchronous operation completes.

const fs = require('fs');

console.log('1. Script started');

// Non-blocking file read
fs.readFile('user.json', 'utf8', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  console.log('3. File content retrieved:', data);
});

console.log('2. Script finished running main thread');

Step-by-Step Callback Flow

  1. Initiation: fs.readFile starts reading the file on an OS-level thread.

  2. Offloading: Node registers the callback function in memory and immediately returns execution back to the main thread.

  3. Non-Blocking Execution: console.log('2. Script finished...') runs right away without waiting for the file read.

  4. Completion: When the file system finishes reading, an event pushes the callback to the event queue. Once the main call stack is clear, Node executes console.log('3. File content...').

This worked fine, for a while. The biggest problem it faced was Callback Hell. Doesn't it sound cool?

// Reading user -> reading permissions -> writing log
fs.readFile('user.json', 'utf8', (err, userData) => {
  if (err) return console.error(err);

  fs.readFile('permissions.json', 'utf8', (err, permData) => {
    if (err) return console.error(err);

    fs.writeFile('log.txt', 'Activity completed', (err) => {
      if (err) return console.error(err);
      console.log('All operations complete successfully.');
    });
  });
});

This actually is a hellish approach for us developers.

It had some Major Pitfalls:

  • Pyramid of Doom: Deep nesting makes the control flow hard to read and track visually.

  • Repetitive Error Handling: You have to check if (err) manually inside every single nested layer.

  • Inversion of Control: You rely on external parameters executing your callbacks at the right time and state.

Promise based async handling

A Promise is an object representing an asynchronous task that would eventually either be resolved or rejected.

const fs = require('fs/promises');

fs.readFile('user.json', 'utf8')
  .then((userData) => {
    return fs.readFile('permissions.json', 'utf8');
  })
  .then((permData) => {
    return fs.writeFile('log.txt', 'Activity completed');
  })
  .then(() => {
    console.log('All operations complete successfully.');
  })
  .catch((err) => {
    // Centralized error handling
    console.error('An error occurred anywhere in the chain:', err);
  });

With each .then() function chained, the data is transferred from the primary function to the .then() function. The standard approach is:

  • .then() block is used for the resolved scenario. And though we can handle the rejections this way too we use,

  • .catch() block for the error scenario.

Promises vs Callback

Feature

Callback Chain

Promise Chain

Visual Flow

Nested horizontally ("Pyramid of Doom")

Linear top-to-bottom pipeline

Error Handling

Repeated if (err) inside every block

Single .catch() block at the end

Return Values

Functions return undefined; data passed via args

Functions return a Promise object holding state

Composition

Difficult to combine or coordinate

Easy via Promise.all() or Promise.race()

A basic interpretation of how a nodejs js code executes.

Conclusion

Hope you're having a good day and this brief article made it a little bit more informative.

This is basically how asynchronous is in the nodejs.