Skip to main content

Command Palette

Search for a command to run...

Understanding Asynchronous JavaScript in Node.js: From Callbacks to Promises

Updated
5 min readView as Markdown

If you’ve spent any time writing Node.js code, you’ve run into asynchronous behavior. It shows up everywhere file handling, API calls, databases and it often leads to confusion early on.

Why does Node.js behave this way? And more importantly, how do you write clean, maintainable async code?

Why Asynchronous Code Exists in Node.js

Node.js is single-threaded. That means one main thread handles all incoming requests and executes your JavaScript.

Now imagine this:

  • A request comes in

  • Your code reads a file from disk (slow operation)

  • The thread waits for it to finish

While it waits, nothing else happens. No other requests are processed.

That would make Node.js unusable at scale.

The Solution: Non-blocking I/O

Instead of waiting, Node.js:

  1. Offloads slow operations (file system, network, DB) to the OS

  2. Continues executing other code

  3. Gets notified when the operation completes

  4. Executes a callback (or Promise handler)

This is what enables Node.js to handle thousands of concurrent connections efficiently.


The Callback Pattern

The original way to handle async operations in Node.js is callbacks.

A callback is simply a function passed into another function, to be executed later.

Basic Example

const fs = require('fs');

fs.readFile('config.json', 'utf8', (err, data) => {
    if (err) {
        console.error('Failed to read file:', err.message);
        return;
    }

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

console.log('This runs first');

Output Order

This runs first
File contents: {...}

That tells you everything: the file read happens later.


How Callbacks Work Internally

Here’s the actual flow:

[Your Code] 
→ [Node delegates to OS] 
→ [Main thread continues]
→ [OS finishes work] 
→ [Callback queued] 
→ [Event loop executes callback]

Execution Flow Diagram

Call fs.readFile
        ↓
Delegated to OS
        ↓
Main thread continues
        ↓
OS completes task
        ↓
Callback added to event loop
        ↓
Callback executes

This is efficient — but not always clean.


The Problem: Callback Hell

Let’s say you want to:

  1. Read a config file

  2. Call an API using it

  3. Write response to a file

Here’s what that looks like with callbacks:

const fs = require('fs');
const https = require('https');

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

    const { apiEndpoint } = JSON.parse(configData);

    https.get(apiEndpoint, (res) => {
        let data = '';

        res.on('data', chunk => data += chunk);

        res.on('end', () => {
            fs.writeFile('output.log', data, (err) => {
                if (err) return console.error(err);

                console.log('Done');
            });
        });
    }).on('error', console.error);
});

Thereare mutiple Issue her :

  • Deep nesting

  • Hard-to-follow flow when function is large

  • Repeated error handling

  • Difficult to scale

This pattern is often called the "pyramid of doom."


Enter Promises

Promises were introduced to solve exactly these issues.

A Promise represents a value that will be available in the future.


Basic Promise Example

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

fs.readFile('config.json', 'utf8')
    .then(data => {
        console.log('File contents:', data);
    })
    .catch(err => {
        console.error('Error:', err.message);
    });

console.log('This runs first');

Same behavior. Cleaner structure.


Chaining Promises

Now let’s rewrite the earlier example using Promises:

const fs = require('fs/promises');
const fetch = require('node-fetch');

fs.readFile('config.json', 'utf8')
    .then(configData => {
        const { apiEndpoint } = JSON.parse(configData);
        return fetch(apiEndpoint);
    })
    .then(response => response.text())
    .then(data => fs.writeFile('output.log', data))
    .then(() => console.log('Done'))
    .catch(err => console.error('Error:', err.message));

Why this is better

  • Flat structure

  • Linear flow (top → bottom)

  • One centralized error handler


Promise Lifecycle

Every Promise has 3 states:

  1. Pending → still running

  2. Fulfilled → success

  3. Rejected → error

Once settled, the state never changes.

Lifecycle Diagram

       Pending
       /     \
Success       Failure
  ↓             ↓
Fulfilled     Rejected
  ↓             ↓
.then()       .catch()

Error Handling Made Simple

Instead of handling errors everywhere:

if (err) return ...

You handle them once:

.catch(err => {
    console.error(err);
});

Graceful Recovery Example

fs.readFile('config.json', 'utf8')
    .catch(() => {
        return JSON.stringify({ apiEndpoint: 'https://default.api' });
    })
    .then(JSON.parse)
    .then(config => {
        console.log(config.apiEndpoint);
    });

Running Async Operations in Parallel

With callbacks, parallel execution is messy.

With Promises:

const fetch = require('node-fetch');

const a = fetch('https://api.com/a').then(r => r.json());
const b = fetch('https://api.com/b').then(r => r.json());

Promise.all([a, b])
    .then(([dataA, dataB]) => {
        console.log(dataA, dataB);
    })
    .catch(console.error);

Benefits we are getting from the Promise

  • No manual tracking

  • Automatic failure handling

  • Cleaner concurrency


Callbacks vs Promises (Side-by-Side)

Callback Version

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

    let user;
    try {
        user = JSON.parse(data);
    } catch (err) {
        return console.error(err);
    }

    console.log(user.name);
});

Promise Version

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

fs.readFile('user.json', 'utf8')
    .then(JSON.parse)
    .then(user => console.log(user.name))
    .catch(console.error);

Key Differences

Aspect Callbacks Promises
Structure Nested Flat
Error handling Repeated Centralized
Readability Decreases with depth Scales well
Composition Hard Easy

Callback Hell vs Promise Flow

Callback Style:
Read → (inside) API → (inside) Write → deeply nested

Promise Style:
Read → then → API → then → Write → linear

Takeaways / TLDR;

  • Node.js uses async I/O to stay fast and scalable

  • Callbacks are fundamental, but hard to manage at scale

  • Promises provide structure, clarity, and composability

  • Promise.all makes concurrency simple

  • Centralized error handling reduces bugs


Final Thoughts

If you’re working with modern Node.js, Promises are not optional — they are the standard foundation.

Once you’re comfortable with them, the next step is async/await, which builds on Promises and makes async code look synchronous again — without blocking.

More from this blog