Sitemap
Ivan Kudria

Ivan Kudria

Experienced Frontend Developer | Expert in Angular, TypeScript, and JavaScript.

Refactoring Asynchronous Code: Should We ALWAYS Move Away from Callbacks?

5 min readDec 18, 2024

--

Press enter or click to view image in full size

When dealing with asynchronous code, we often see examples showcasing Promise chains or async/await. Many developers write their code this way, believing it ensures scalability or simply adhering to a preferred coding style.

But what if the task is simply to call a function and handle its result? Business requirements often change, and what started as a simple function call might evolve to include additional requests or operations. In such cases, even in modern applications, you can still encounter the infamous “callback hell.”

Let’s take a look at an example: three functions initially created as independent units. These functions are purely for demonstration purposes:

function fetchUserData(userId, callback) {
console.log('(1) Fetching user data...');
setTimeout(() => {
if (userId === 1) {
console.log('(1) User data fetched');
callback({ id: 1, name: 'John Doe', role: 'admin' }, null);
} else {
callback(null, 'User not found');
}
}, 1000);
}

function fetchUserPermissions(role, callback) {
console.log('(2) Fetching permissions for role:', role);
setTimeout(() => {
if (role === 'admin') {
console.log('(2) Permissions fetched');
callback(['read', 'write', 'delete'], null);
} else {
callback(null, 'Permissions not found for this role');
}
}, 1000);
}

function logAccessAttempt(user, permissions, callback) {
console.log('(3) Logging access attempt...');
setTimeout(() => {
if (permissions.includes('write')) {
console.log(`(3) Access logged for user: ${user.name}`);
callback('Access logged successfully', null);
} else {
callback(null, 'Insufficient permissions for access logging');
}
}, 1000);
}

But as requirements evolve, you may need to call them sequentially:

fetchUserData(1, (user, err) => {
if (err) return console.log(err);
fetchUserPermissions(user.role, (permissions, err) => {
if (err) return console.log(err);
logAccessAttempt(user, permissions, (result, err) => {
if (err) return console.log(err);
console.log(result);
});
});
});

And there it is — the dreaded “callback hell.”

The first thought might be to rewrite everything using Promise or async/await. These approaches are widely regarded as modern standards, with countless articles and videos explaining how to implement them.

But what if that’s not an option? For example, the code may already be widely used, or the changes could take too much time.

How can we address the issue without completely rewriting the codebase? Let’s explore some solutions.

The first thing we can do is introduce named functions.
These functions act as a middle layer between calls, separating responsibilities and reducing nesting. Here’s what this approach looks like:

const handleLogAccessAttempt = (result, err) => {
if (err) {
console.log(err);
return;
}
console.log(result);
};

const handleUserPermissions = (user) => (permissions, err) => {
if (err) {
console.log(err);
return;
}
logAccessAttempt(user, permissions, handleLogAccessAttempt);
};

const handleUser = (user, err) => {
if (err) {
console.log(err);
return;
}
fetchUserPermissions(user.role, handleUserPermissions(user));
};

fetchUserData(1, handleUser);

As you can see, the functions are no longer nested within one another.
The handle functions create a clean and modular composition, making the code easier to read and maintain.

Benefits of this approach:

  1. Improved readability:
    The separation of responsibilities allows each function to do one specific task, making the flow easier to follow.
  2. Easier testing:
    For example, you can mock fetchUserPermissions in a test and verify that handleUserPermissions correctly passes data to logAccessAttempt without worrying about the other parts of the chain.
  3. Reusability:
    The handle functions can be reused in other parts of the application. For example, handleLogAccessAttempt could be used in a different context where access logging is required.

While this approach addresses callback hell, it still involves some manual composition.
One key concept in this approach is the use of closures to pass data between steps.

For example, in this snippet:

const handleUserPermissions = (user) => (permissions, err) => {
if (err) {
console.log(err);
return;
}
logAccessAttempt(user, permissions, handleLogAccessAttempt);
};

The handleUserPermissions function creates a closure around the user parameter. This allows the inner function (permissions, err) to access the user object without having to explicitly pass it through every step.

In the next step, we’ll explore how to make the code even cleaner and more concise.

Module: Wrapping handle Functions in a Class

As we observed earlier, the code progresses in a “non-natural order.”
To use handle functions, we start from the last step and work our way backwards, which can be unintuitive. Instead of rewriting the original functions using Promise or async/await, we can create a wrapper module that connects these functions and provides a unified interface.

Get Ivan Kudria’s stories in your inbox

Join Medium for free to get updates from this writer.

The idea:
We create a class that:

  1. Imports and uses the original functions as they are.
  2. Implements handle functions as methods of the class.
  3. Encapsulates the process, making the code easier to read and maintain.

Here’s an example:

class UserService {
constructor() {
this.user = null;
this.permissions = null;
}

handleLogAccessAttempt(result, err) {
if (err) {
console.log(err);
return;
}
console.log(result);
}

handleUserPermissions(permissions, err) {
if (err) {
console.log(err);
return;
}
this.permissions = permissions;
logAccessAttempt(this.user, this.permissions, this.handleLogAccessAttempt.bind(this));
}

handleUser(user, err) {
if (err) {
console.log(err);
return;
}
this.user = user;
fetchUserPermissions(this.user.role, this.handleUserPermissions.bind(this));
}

fetchUser(userId) {
fetchUserData(userId, this.handleUser.bind(this));
}
}

const userService = new UserService();
userService.fetchUser(1);

Benefits of this approach:

1. Modularity

  • The UserService class encapsulates the entire logic, providing a single execute method to perform all steps.
  • The original functions remain unchanged, making this approach non-invasive.

2. Readability

  • The handle methods are encapsulated within the class, making the flow easier to follow.
  • The code starts from the main action (execute) and flows logically.

3. Testing and Maintainability

  • Each handle function is now a method of the class, making it easier to test in isolation.
  • The logic is centralized, making the code easier to update or extend.

A Key Technical Detail: Context Preservation with bind

Since the original functions (fetchUserData, fetchUserPermissions, logAccessAttempt) are not part of the class, they don’t automatically have access to the class instance (this).

To avoid losing context, bind is used to ensure that this in the handle methods always refers to the class instance.

For example:

fetchUserData(userId, this.handleUser.bind(this));

This ensures that the class methods can correctly reference this when they are used as callbacks.

Conclusion

The approach demonstrated above is one way to address callback hell without altering the original functions or their calls. It also serves as a reminder that callbacks are not inherently a bad solution, nor are they always the enemy.

Callbacks are a fundamental structure in JavaScript, and when used effectively — especially with closures and the ability to invoke functions in different contexts — they can be a perfectly valid and practical tool for managing asynchronous code.

While modern approaches like Promise and async/await offer cleaner syntax and are often recommended, it’s important to evaluate the context of your project. In some cases, sticking with callbacks and improving their structure may be the simpler and more efficient path forward.

--

--

Ivan Kudria
Ivan Kudria

Experienced Frontend Developer | Expert in Angular, TypeScript, and JavaScript.