DEV Community

Harman Panwar
Harman Panwar

Posted on

Async Code in Node.js: Callbacks and Promises

Asynchronous JavaScript in Node.js: From Callbacks to Promises

Node.js is built around the idea that waiting is wasteful. When your program needs to read a file, query a database, or fetch data from an API, the actual work is delegated to the operating system or external service. Rather than blocking the execution thread until that work completes, Node.js continues running other code and notifies you when the result is ready. This asynchronous model is the reason Node.js can handle thousands of simultaneous operations with a single thread. This guide explains why async code exists in Node.js, how callbacks work, what goes wrong when they nest too deeply, and how promises solve those problems.


Why Async Code Exists in Node.js

The Single-Threaded Reality

Node.js runs your JavaScript on a single thread. This means only one line of your code executes at any given moment. If that thread encounters an operation that takes time — reading from disk, waiting for a network response, querying a database — the entire thread would sit idle if it waited synchronously.

The problem with waiting:

const fs = require('fs');

// Synchronous read: The thread does NOTHING for 100ms
const data = fs.readFileSync('config.json', 'utf8');
console.log(data);

// During those 100ms, no other code can run
// No other requests can be handled
// The CPU is idle while the disk spins
Enter fullscreen mode Exit fullscreen mode

In a web server handling hundreds of requests per second, one blocking operation creates a traffic jam. Request 2 cannot start until Request 1's file read finishes. Request 3 waits for Request 2. The queue grows, response times explode, and users see spinning loading indicators.

The Non-Blocking Solution

Node.js solves this by making I/O operations non-blocking. When you request a file read, Node.js:

  1. Hands the request to the operating system
  2. Immediately returns control to your program
  3. Continues executing whatever comes next
  4. When the OS finishes reading, Node.js runs your callback with the result
const fs = require('fs');

// Asynchronous read: The thread moves on immediately
fs.readFile('config.json', 'utf8', (err, data) => {
  // This runs later, when the file is ready
  console.log(data);
});

// This runs immediately, before the file is read
console.log('File read initiated, continuing with other work...');
Enter fullscreen mode Exit fullscreen mode

Execution flow:

Time →
│
├─ fs.readFile() called ─────────────→ OS starts reading
├─ Thread is FREE ───────────────────→ Other code runs
├─ Other code runs ──────────────────→ More work done
├─ [100ms pass] ─────────────────────→ OS read completes
├─ Callback queued ──────────────────→ Event loop picks it up
├─ Callback executes ────────────────→ console.log(data)
│
Enter fullscreen mode Exit fullscreen mode

The thread was never idle. It handled other tasks while the disk did its work. This is why async code exists — not because it makes programming easier, but because it makes efficient use of the single thread Node.js provides.


Callback-Based Async Execution

What a Callback Is

A callback is a function passed as an argument to another function, which is then invoked inside the outer function to complete some kind of routine or action. In asynchronous Node.js, callbacks are invoked when an operation completes.

File Reading Example Scenario

The most common introduction to Node.js async code is file system operations. Let's trace a complete example step by step.

Step 1: The Basic Read

const fs = require('fs');

// readFile takes three arguments:
// 1. Path to the file
// 2. Encoding (optional, but recommended for text)
// 3. Callback function to run when complete

fs.readFile('data.txt', 'utf8', function callback(error, data) {
  if (error) {
    console.error('Failed to read:', error.message);
    return;
  }

  console.log('File contents:');
  console.log(data);
});

console.log('This prints first — readFile is non-blocking');
Enter fullscreen mode Exit fullscreen mode

Callback flow step-by-step:

  1. Call: fs.readFile('data.txt', 'utf8', callback) is invoked
  2. Delegate: Node.js passes the read request to the operating system
  3. Return: fs.readFile returns immediately (return value is undefined)
  4. Continue: The next line (console.log) executes
  5. Wait: The OS reads the file from disk (this happens in parallel)
  6. Complete: The OS signals that the read is done
  7. Queue: Node.js adds the callback to the event loop's task queue
  8. Execute: When the event loop reaches this task, the callback runs
  9. Handle: Inside the callback, error is null, data contains the file text
  10. Output: console.log(data) prints the file contents

Step 2: The Error-First Convention

Node.js callbacks follow an error-first convention: the first argument is always an error object (or null if there was no error), and the second argument is the result data.

fs.readFile('data.txt', 'utf8', (error, data) => {
  // ALWAYS check error first
  if (error) {
    // Handle the error — do not proceed
    console.error('Read failed:', error.code);

    if (error.code === 'ENOENT') {
      console.error('File does not exist');
    }
    return;
  }

  // Only reached if no error
  console.log('Success:', data);
});
Enter fullscreen mode Exit fullscreen mode
Error Code Meaning
ENOENT File or directory does not exist
EACCES Permission denied
EISDIR Tried to read a directory as a file
EMFILE Too many open files

Step 3: Multiple Async Operations in Sequence

Reading one file is simple. Real applications chain multiple operations:

const fs = require('fs');

// Goal: Read config → Read database credentials → Connect to database

fs.readFile('config.json', 'utf8', (err, configData) => {
  if (err) {
    console.error('Cannot load config:', err);
    return;
  }

  const config = JSON.parse(configData);

  fs.readFile(config.dbCredentialsPath, 'utf8', (err, credsData) => {
    if (err) {
      console.error('Cannot load credentials:', err);
      return;
    }

    const credentials = JSON.parse(credsData);

    connectToDatabase(credentials, (err, connection) => {
      if (err) {
        console.error('Cannot connect:', err);
        return;
      }

      connection.query('SELECT * FROM users', (err, results) => {
        if (err) {
          console.error('Query failed:', err);
          connection.close();
          return;
        }

        console.log('Users:', results);
        connection.close();
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

What this does:

  1. Reads config.json
  2. Parses it to find the database credentials file path
  3. Reads the credentials file
  4. Connects to the database using those credentials
  5. Runs a query
  6. Prints results and closes the connection

Each step depends on the previous one, so they must run sequentially. Each step has its own error check.


Problems with Nested Callbacks

Callback Hell (The Pyramid of Doom)

The previous example demonstrates what happens when callbacks nest inside callbacks. The code indents further right with each layer, creating a visual pyramid:

fs.readFile('a.txt', (err, a) => {
  fs.readFile('b.txt', (err, b) => {
    fs.readFile('c.txt', (err, c) => {
      fs.readFile('d.txt', (err, d) => {
        fs.readFile('e.txt', (err, e) => {
          // Deep inside the pyramid...
          // Variables from outer scopes are hard to access
          // Error handling is repeated at every level
          // Adding a step means nesting deeper
        });
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

The problems this creates:

Problem How It Manifests Impact
Deep nesting Code drifts right, creating a pyramid shape Hard to read, harder to maintain
Error handling repetition Every callback needs its own if (err) block Boilerplate obscures logic
Inversion of control You pass your logic to another function, trusting it to call back correctly Callbacks might fire twice, never, or with wrong arguments
Variable scope issues Variables from early steps are inaccessible in deep callbacks Forces declaration of outer variables, creating state management problems
No return values You cannot return from a callback and use the value elsewhere Forces continuation-passing style throughout
Sequential composition Running steps in order requires manual nesting Parallel execution is even more complex

The Inversion of Control Problem

With callbacks, you hand your function to another function and hope it behaves:

// You give your callback to readFile
fs.readFile('data.txt', callback);

// What if readFile:
// - Calls callback twice? (double processing)
// - Never calls it? (hanging forever)
// - Calls it synchronously instead of async? (unexpected timing)
// - Swallows errors silently? (lost failures)
Enter fullscreen mode Exit fullscreen mode

You lose control over when and how your code executes. This is called inversion of control — a source of subtle, hard-to-debug bugs.

Error Handling Complexity

In a nested callback chain, error handling is scattered:

operation1((err, result1) => {
  if (err) { /* handle */ return; }

  operation2(result1, (err, result2) => {
    if (err) { /* handle */ return; }

    operation3(result2, (err, result3) => {
      if (err) { /* handle */ return; }

      operation4(result3, (err, result4) => {
        if (err) { /* handle */ return; }

        // Finally, success
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Five operations = five identical error checks. There is no centralized way to catch failures. If you forget a check at any level, errors bubble up unpredictably or get swallowed.


Promise-Based Async Handling

What Promises Change

Promises restructure async code by:

  • Returning an object immediately (the promise) instead of taking a callback
  • Attaching handlers to that object for success (.then()) and failure (.catch())
  • Chaining operations vertically instead of nesting them horizontally
  • Centralizing error handling at the end of the chain

The Same File Reading with Promises

const fs = require('fs').promises;  // Node.js provides promise versions

// readFile now returns a promise instead of taking a callback
fs.readFile('config.json', 'utf8')
  .then(configData => {
    const config = JSON.parse(configData);
    return fs.readFile(config.dbCredentialsPath, 'utf8');
  })
  .then(credsData => {
    const credentials = JSON.parse(credsData);
    return connectToDatabasePromise(credentials);
  })
  .then(connection => {
    return connection.query('SELECT * FROM users');
  })
  .then(results => {
    console.log('Users:', results);
  })
  .catch(error => {
    // ONE error handler for ANY failure in the chain
    console.error('Operation failed:', error.message);
  });
Enter fullscreen mode Exit fullscreen mode

What changed:

  • The pyramid became a vertical chain
  • One .catch() replaces five if (err) checks
  • Each .then() receives the previous result and returns the next promise
  • The code reads top-to-bottom like synchronous logic

How Promise Chaining Works

fetchUser(123)                    // Returns Promise<User>
  .then(user => {                // Receives User, returns Promise<Orders>
    return fetchOrders(user.id);
  })
  .then(orders => {              // Receives Orders, returns Promise<Details>
    return fetchDetails(orders[0].id);
  })
  .then(details => {             // Receives Details
    console.log(details);
  })
  .catch(error => {              // Catches ANY error above
    console.error(error);
  });
Enter fullscreen mode Exit fullscreen mode

Each .then() returns a new promise. The chain waits for each promise to resolve before moving to the next .then(). If any promise rejects, the chain jumps to .catch().

Creating Your Own Promises

When working with callback-based libraries, you can wrap them in promises:

function readFilePromise(path, encoding) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, encoding, (error, data) => {
      if (error) {
        reject(error);    // Promise becomes rejected
      } else {
        resolve(data);    // Promise becomes fulfilled
      }
    });
  });
}

// Usage
readFilePromise('config.json', 'utf8')
  .then(data => console.log(data))
  .catch(error => console.error(error));
Enter fullscreen mode Exit fullscreen mode

Benefits of Promises

Readability Comparison

Task: Read config, read credentials, connect to database, run query.

Aspect Callbacks Promises
Lines of code ~35 ~15
Indentation depth 4 levels 1 level
Error checks 4 separate if (err) blocks 1 .catch()
Visual flow Pyramid (→ right) Vertical list (↓ straight)
Variable access Limited by nesting scope Each .then() receives previous result
Adding a step Nest another callback Add another .then()

Composability

Promises are values you can pass around, unlike callbacks which are tied to their creation context:

// Create a promise and pass it around
const userPromise = fetchUser(123);

// Use it in multiple places
userPromise.then(user => console.log(user.name));
userPromise.then(user => updateCache(user));
userPromise.catch(err => logError(err));

// Combine multiple promises
Promise.all([
  fetchUser(123),
  fetchOrders(123),
  fetchSettings(123)
]).then(([user, orders, settings]) => {
  renderDashboard(user, orders, settings);
});
Enter fullscreen mode Exit fullscreen mode

Error Propagation

Errors propagate down the chain automatically. You do not manually pass them:

fetchUser(123)
  .then(user => fetchOrders(user.id))
  .then(orders => processOrders(orders))
  .then(result => saveToDatabase(result))
  .catch(error => {
    // This catches errors from fetchUser, fetchOrders, processOrders, OR saveToDatabase
    // No manual error passing needed
    console.error('Chain failed:', error);
  });
Enter fullscreen mode Exit fullscreen mode

Centralized Cleanup

The .finally() method runs regardless of success or failure:

showLoadingSpinner();

fetchData()
  .then(data => renderPage(data))
  .catch(error => showError(error))
  .finally(() => {
    // Always hides the spinner
    hideLoadingSpinner();
  });
Enter fullscreen mode Exit fullscreen mode

With callbacks, cleanup requires duplication in both success and error paths.

The Mental Model Shift

With Callbacks With Promises
"Tell me when you are done by calling this function" "Give me a token representing the future result"
"Here is what to do next" "Attach handlers to this future value"
Nested, temporal logic Flat, value-oriented logic
"After X, do Y" "X produces a value, then Y consumes it"

Summary

Concept What It Means Why It Matters
Async code in Node.js Non-blocking operations that delegate to the OS and continue execution Prevents thread idle time, enables massive concurrency
Callback Function passed to an async operation, invoked upon completion The original async pattern in Node.js
Callback hell Deeply nested callbacks creating pyramid-shaped code Unreadable, unmaintainable, error-prone
Inversion of control Passing your logic to another function to execute later Loss of control over execution timing and guarantees
Promise An object representing a future value, with .then() and .catch() handlers Flattens callback pyramids, centralizes errors, enables composition
Promise chaining Each .then() returns a new promise, creating a vertical sequence Sequential async operations read like synchronous code
.catch() Single error handler for an entire promise chain Eliminates repetitive error checking
.finally() Runs after success or failure Centralized cleanup without duplication

Node.js asynchronous code exists because blocking the single thread is not an option. Callbacks were the first solution, and they work — but they create maintenance nightmares as complexity grows. Promises do not change the underlying async nature of Node.js; they change how developers think about and write async code. The operations still delegate to the OS, the thread still remains free, and the event loop still dispatches completion handlers. What changes is the shape of the code: from nested pyramids to flat chains, from scattered error checks to centralized handling, from temporal callbacks to composable values.

Remember: Callbacks are like leaving a voicemail — you give someone instructions and hope they follow them. Promises are like receiving a receipt — you hold a tangible object representing a future outcome, and you decide what to do with it when it arrives. Both achieve the same result, but one gives you far more control and clarity.

Top comments (0)