DEV Community

Harman Panwar
Harman Panwar

Posted on

Callbacks in JavaScript: Why They Exist

If you've spent more than five minutes writing JavaScript in Node.js, you've likely encountered the infamous asynchronous nature of the language. It's powerful, but it can also be a source of deep frustration---especially when you start nesting callbacks inside callbacks inside callbacks.

In this guide, we'll explore why async code exists, how we used to handle it with callbacks, why that led to "Callback Hell," and how Promises revolutionized everything.

Let's dive in.


Why Async Code Exists in Node.js

JavaScript is single-threaded. That means it can only do one thing at a time. In a web browser, blocking that one thread would freeze the entire UI. In Node.js, blocking the thread would prevent the server from handling any other requests.

Imagine a restaurant with only one waiter. If that waiter takes an order, walks to the kitchen, and stands there waiting for the food to cook, no other customers get served.

Async code solves this by allowing the waiter to take an order, tell the kitchen to start cooking, and then immediately go serve other tables. When the food is ready, the kitchen notifies the waiter, who then delivers it.

In technical terms: I/O operations (file reads, database queries, network requests) are slow. Async execution lets Node.js handle other work while waiting for those operations to complete.


The Classic Scenario: Reading a File

Let's look at a real example. Suppose we need to read three files sequentially: user.json, then posts.json (for that user), then comments.json (for those posts).

Here's how we might imagine it working synchronously:

javascript

const user = readFileSync('user.json');
const posts = readFileSync('posts.json');
const comments = readFileSync('comments.json');

But don't do this in production---it blocks the entire server.

Instead, Node.js gives us async methods.


Callback-Based Async Execution

The original way to handle async code in Node.js was with callbacks---functions passed as arguments to be executed later.

javascript

const fs = require('fs');

console.log('Start reading files...');

fs.readFile('user.json', 'utf8', (err, userData) => {
if (err) {
console.error('Error reading user.json:', err);
return;
}
console.log('✅ User data loaded');

fs.readFile('posts.json', 'utf8', (err, postsData) => {
if (err) {
console.error('Error reading posts.json:', err);
return;
}
console.log('✅ Posts data loaded');

fs.readFile('comments.json', 'utf8', (err, commentsData) => {
  if (err) {
    console.error('Error reading comments.json:', err);
    return;
  }
  console.log('✅ Comments data loaded');
  console.log('All files loaded!');
});
Enter fullscreen mode Exit fullscreen mode

});
});

console.log('This runs FIRST, even though it appears last!');

Step-by-Step Flow

  1. Start reading files... is logged.

  2. This runs FIRST... is logged immediately after (because readFile is non-blocking).

  3. Meanwhile, Node.js reads user.json in the background.

  4. When user.json is ready, its callback runs.

  5. Inside that callback, we start reading posts.json.

  6. When posts.json is ready, its callback runs.

  7. Inside that callback, we start reading comments.json.

  8. Finally, comments.json loads and we log All files loaded!.

This works, but we're already seeing the problem...


The Problem with Nested Callbacks (Callback Hell)

As your logic grows, the nesting becomes deeper and harder to read:

javascript

getUser(id, (err, user) => {
if (err) handleError(err);
getPosts(user.id, (err, posts) => {
if (err) handleError(err);
getComments(posts[0].id, (err, comments) => {
if (err) handleError(err);
getReactions(comments[0].id, (err, reactions) => {
if (err) handleError(err);
// 🤯 I've lost count of how many levels this is
});
});
});
});

This is affectionately (or not so affectionately) known as Callback Hell or the Pyramid of Doom.

Why Callback Hell Hurts:

  • Readability -- The code flows rightward like a sideways pyramid.

  • Error handling -- Every single callback needs its own error check.

  • Maintainability -- Adding a step in the middle requires re-indenting entire blocks.

  • Debugging -- Stack traces become confusing across asynchronous boundaries.


Promise-Based Async Handling: The Rescue

Promises arrived to save us from this madness. A Promise represents a value that may not be available yet, but will be at some point (or will fail).

A Promise is in one of three states:

  • Pending -- Initial state, neither fulfilled nor rejected.

  • Fulfilled -- Operation completed successfully.

  • Rejected -- Operation failed.

Here's the same file-reading logic using fs.promises (built into modern Node.js):

javascript

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

async function loadAllData() {
try {
console.log('Start reading files...');

const userData = await fs.readFile('user.json', 'utf8');
console.log('✅ User data loaded');

const postsData = await fs.readFile('posts.json', 'utf8');
console.log('✅ Posts data loaded');

const commentsData = await fs.readFile('comments.json', 'utf8');
console.log('✅ Comments data loaded');

console.log('All files loaded!');
return { userData, postsData, commentsData };
Enter fullscreen mode Exit fullscreen mode

} catch (err) {
console.error('Something went wrong:', err);
}
}

loadAllData();

Or without async/await, using Promise chaining:

javascript

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

fs.readFile('user.json', 'utf8')
.then(userData => {
console.log('✅ User data loaded');
return fs.readFile('posts.json', 'utf8');
})
.then(postsData => {
console.log('✅ Posts data loaded');
return fs.readFile('comments.json', 'utf8');
})
.then(commentsData => {
console.log('✅ Comments data loaded');
console.log('All files loaded!');
})
.catch(err => {
console.error('Something went wrong:', err);
});


Benefits of Promises (vs. Callbacks)

Let's compare the same logic side-by-side:

Callback Version (Nested)

javascript

fs.readFile('user.json', (err, user) => {
if (err) throw err;
fs.readFile('posts.json', (err, posts) => {
if (err) throw err;
fs.readFile('comments.json', (err, comments) => {
if (err) throw err;
// finally do something
});
});
});

Promise Version (Flat)

javascript

fs.readFile('user.json')
.then(user => fs.readFile('posts.json'))
.then(posts => fs.readFile('comments.json'))
.then(comments => {
// do something with all data
})
.catch(err => console.error(err));

Key Benefits:

Feature Callbacks Promises
Readability Pyramid of Doom Flat chain or async/await
Error Handling Separate for each callback Single .catch() or try/catch
Composition Manual nesting Promise.all(), Promise.race(), etc.
Return Values Must nest to access Naturally return values through chain
Debugging Poor stack traces Better context
Backpressure/Flow Control Manual Built-in with async/await

Pro Tips & Suggestions

1. Always use async/await over .then() chaining when possible

javascript

// Good
const data = await fetchData();

// Less readable for complex flows
fetchData().then(data => {...});

2. Handle parallel operations with Promise.all()

If files don't depend on each other, load them in parallel:

javascript

const [user, posts, comments] = await Promise.all([
fs.readFile('user.json', 'utf8'),
fs.readFile('posts.json', 'utf8'),
fs.readFile('comments.json', 'utf8')
]);

Warning: If one fails, all fail. Use Promise.allSettled() if you need partial results.

3. Convert callback-based APIs to Promises

Many libraries still use callbacks. Use util.promisify:

javascript

const { promisify } = require('util');
const readFileAsync = promisify(fs.readFile);

const data = await readFileAsync('file.txt', 'utf8');

4. Always handle errors

javascript

try {
const data = await riskyOperation();
} catch (error) {
// Log properly, don't just console.error
logger.error('Operation failed:', error);
// Optionally rethrow or return a fallback
}

5. Avoid mixing then/catch with async/await in the same function

Pick one style and stick to it for consistency.


Summary

Approach When to use
Callbacks Never (unless maintaining legacy code)
Promises (.then) Simple chains without complex logic
Async/Await Preferred for almost all modern Node.js code

Node.js started with callbacks because that was the JavaScript way. But as applications grew, the limitations became clear. Promises (and especially async/await) give us back the ability to write asynchronous code that looks and feels synchronous---without blocking the event loop.

Your future self (and your teammates) will thank you for choosing Promises.

Top comments (0)