Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: From Callback Hell to Promises

Updated
7 min read
Async Code in Node.js: From Callback Hell to Promises

Audience: This post assumes familiarity with JavaScript fundamentals and basic Node.js. You don't need to know how the event loop works internally, but you should be comfortable writing functions and handling errors.

TL;DR: Node.js uses a single-threaded event loop that delegates I/O to the OS. Callbacks were the original way to handle async results — they work, but nest badly. Promises give you a flat, chainable alternative with better error propagation.


Problem

You need to read a config file, parse it, fetch a remote resource using values from that config, and write the result to disk. Every step depends on the previous one. Every step is asynchronous.

With callbacks, this turns into a pyramid of doom. With Promises, it's a clean chain. Understanding why requires looking at what Node.js is actually doing when it runs your code.


Why Async Code Exists in Node.js

Node.js runs on a single thread. That single thread runs your JavaScript. If it waits for a file to read or a network response to arrive, nothing else runs — no other requests, no timers, nothing.

So Node.js doesn't wait. Instead, it:

  1. Registers the I/O operation with the operating system

  2. Moves on to the next task

  3. Gets notified when the I/O is done

  4. Executes the handler you registered

This is the event loop. Your async handlers — callbacks or Promise handlers — run in response to events, not in sequence with the main flow.

This architecture is why Node.js can handle thousands of concurrent connections without threads. But it shifts the complexity to you: you have to write code that explicitly declares what to do after something happens.


Solution

Callback-Based Async Execution

A callback is just a function you pass to another function, to be called when an async operation completes.

Here's a real example — reading a file:

// callback-read.js
const fs = require('fs');

fs.readFile('./config.json', 'utf8', function(err, data) {
  if (err) {
    console.error('Failed to read config:', err.message);
    return;
  }
  console.log('Config loaded:', data);
});

console.log('This runs before the file is read.');

Expected output:

This runs before the file is read.
Config loaded: { "apiUrl": "https://api.example.com", "timeout": 5000 }

The last console.log fires first because readFile is non-blocking. The callback fires later, when the OS delivers the file data.

Callback execution flow:

Main thread:
  → registers readFile with OS
  → logs "This runs before..."
  → event loop: checks for completed I/O
  → OS signals: file is ready
  → callback fires with (err, data)

This works. The problem starts when you chain operations.


The Nested Callback Problem

Now extend the scenario: read a config file, parse it, then write a result to disk.

// callback-chain.js
const fs = require('fs');
const https = require('https');

fs.readFile('./config.json', 'utf8', function(err, configData) {
  if (err) {
    console.error('Step 1 failed:', err.message);
    return;
  }

  let config;
  try {
    config = JSON.parse(configData);
  } catch (parseErr) {
    console.error('Step 2 failed:', parseErr.message);
    return;
  }

  https.get(config.apiUrl, function(response) {
    let body = '';

    response.on('data', function(chunk) {
      body += chunk;
    });

    response.on('end', function() {
      fs.writeFile('./result.json', body, 'utf8', function(writeErr) {
        if (writeErr) {
          console.error('Step 3 failed:', writeErr.message);
          return;
        }
        console.log('Pipeline complete. Result written to result.json');
      });
    });

    response.on('error', function(requestErr) {
      console.error('Request failed:', requestErr.message);
    });

  }).on('error', function(err) {
    console.error('HTTP error:', err.message);
  });
});

This is already hard to follow. Each step pushes the code one level deeper. Error handling is scattered across multiple if (err) checks and .on('error') handlers. There's no single place where errors converge.

Add one more step and you have callback hell — not a metaphor, a real structural problem:

readFile(
  parseConfig(
    fetchData(
      processData(
        writeResult()
      )
    )
  )
)

Debugging this means tracing execution across 4+ indentation levels. Refactoring means touching every nesting layer.


Promise-Based Async Handling

A Promise represents a value that will be available in the future. It exists in one of three states:

Pending → Fulfilled (resolved with a value)
       → Rejected (failed with a reason)

Once settled, a Promise never changes state. This predictability is what makes them composable.

Promise lifecycle:

 new Promise(executor)
       │
       ▼
   [Pending]
       │
  ┌────┴────┐
  ▼         ▼
[Fulfilled] [Rejected]
  │              │
 .then()      .catch()
  │              │
  └──────┬───────┘
         ▼
    next .then()

Here's the same file-read operation using fs.promises, the native Promise-based API:

// promise-read.js
const fs = require('fs').promises;

fs.readFile('./config.json', 'utf8')
  .then(function(data) {
    console.log('Config loaded:', data);
  })
  .catch(function(err) {
    console.error('Failed to read config:', err.message);
  });

console.log('This still runs before the file is read.');

Now rewrite the full pipeline with Promises:

// promise-chain.js
const fs = require('fs').promises;
const https = require('https');

function fetchUrl(url) {
  return new Promise(function(resolve, reject) {
    https.get(url, function(response) {
      let body = '';
      response.on('data', function(chunk) { body += chunk; });
      response.on('end', function() { resolve(body); });
      response.on('error', reject);
    }).on('error', reject);
  });
}

fs.readFile('./config.json', 'utf8')
  .then(function(configData) {
    return JSON.parse(configData);
  })
  .then(function(config) {
    return fetchUrl(config.apiUrl);
  })
  .then(function(responseBody) {
    return fs.writeFile('./result.json', responseBody, 'utf8');
  })
  .then(function() {
    console.log('Pipeline complete. Result written to result.json');
  })
  .catch(function(err) {
    console.error('Pipeline failed at some step:', err.message);
  });

What changed:

  • Flat indentation regardless of how many steps you add

  • A single .catch() handles errors from any step in the chain

  • Each .then() receives the resolved value of the previous step

  • JSON.parse throws synchronously — Promise chains catch that too, because any exception thrown inside a .then() becomes a rejection


Why .catch() at the End Works

When you throw (or return a rejected Promise) inside a .then(), the next .then() in the chain is skipped and control jumps to the nearest .catch(). This is how a single error handler covers the entire pipeline.

Promise.resolve('start')
  .then(function(value) {
    throw new Error('something broke at step 1');
  })
  .then(function(value) {
    // This is skipped
    console.log('step 2:', value);
  })
  .catch(function(err) {
    console.error('Caught:', err.message);
    // Output: Caught: something broke at step 1
  });

Callback vs Promise: Side-by-Side

Property Callbacks Promises
Error handling Per-callback if (err) Single .catch()
Nesting Grows with each step Flat .then() chain
Composability Hard — requires utilities Native .then() chaining
Sync exceptions Uncaught unless try/catch Automatically rejected
Readability at 5+ steps Poor Acceptable

Results

  • Error handling goes from N scattered checks to 1 .catch()

  • Adding a new async step means one new .then(), not a new nesting level

  • Synchronous exceptions inside .then() are automatically converted to rejections — no try/catch wrapper needed at each step

  • Existing Node.js built-ins (fs.promises, dns.promises, etc.) expose native Promise APIs


Trade-offs

Promises are not free of issues:

  • Stack traces are weaker. When a Promise rejects, the stack trace often points to the internals of the Promise implementation, not the line that caused the problem. This improved in modern Node.js versions but is still occasionally frustrating.

  • Unhandled rejections crash the process. Since Node.js 15, an unhandled Promise rejection exits the process. This is the right behavior, but it catches teams off guard when migrating legacy code.

  • .then() chaining is still verbose. For complex flows, async/await (which compiles down to Promises) is cleaner. Promises are the foundation — async/await is the ergonomic layer on top.

  • Callbacks are not obsolete. Event emitters (EventEmitter), streams, and low-level APIs still use callbacks. You need to understand both.


Conclusion

Callbacks are the primitive mechanism Node.js uses to handle async results. They work, but they compose badly — each new step adds nesting and scatters error handling. Promises impose a contract: one resolved value or one rejection, propagated through a flat chain with a single error handler.

Understanding Promises is also the prerequisite for understanding async/await, which is just syntactic sugar over the same Promise machinery. Before you write await, you should know what it's waiting for.

Next step: Refactor the promise-chain.js example above using async/await and observe how the same Promise chain becomes synchronous-looking code without changing the underlying execution model.


Further Reading

More from this blog

ThitaInfo Blogs

63 posts

Making AI simple, fun, and practical for developers.