Node.js: how to avoid callback hell without breaking the Event Loop

Node.js: how to avoid callback hell without breaking the Event Loop

We can avoid callback hell in Node.js without breaking the Event Loop.

We can avoid callback hell in Node.js without breaking the Event Loop.

JavaScript operates on a single thread. Node.js through the libuv library uses this feature to manage execution through the Event Loop which allows operations that do not block the I/O flow.

The main feature of libuv that allows Node.js to support the asynchronous execution of JavaScript it's the direct handling of kernel events and I/O operations such as TCP and UDP sockets, DNS resolution, file system operations and related events.

In libuv all these operations are asynchronous, but the most important aspect of this library is that through epoll, kqueue, IOCP and events it provides Node.js with the foundations for building its Event Loop.

The documentation on the Event Loop illustrates the phases of this cycle, but also reveals a problem inherent in the Node.js API design: in addition to the traditional asynchronous methods, they also provide synchronous methods that block the Event Loop in a given phase, thus preventing Node from processing subsequent steps.

A typical example is the method of the core fs module. As long as the contents of a file are already predetermined and its dimensions are fixed, known and pre-established (e.g. whene we read a private key and an SSL certificate), this synchronous method has no negative impact on the Event Loop. On the contrary, and especially when synchronous reading is exposed in the frontend through HTTP/S (as in the case of images), you can have serious repercussions on the overall performance and expose your site to DOS attacks that not only block the execution of Node but also have a huge impact on the global performance of the server hosting the site.

If we essentially want to avoid the callback hell related to the syntax itself of asynchronous methods, we can adopt the latest ECMAScript features, such as the Promises and the async/await construct which preserve the asynchronicity of the operations and provide us with a simpler syntax (no unnecessary nesting of functions within functions).

For example, we can create the following utility class that uses Promises:

'use strict';

const path = require('path');
const fs = require('fs');
const ABSPATH = path.dirname(process.mainModule.filename);

class Files {
    static read(path, encoding = 'utf8') {
      return new Promise((resolve, reject) => {
        let readStream = fs.createReadStream(ABSPATH + path, encoding);
        let data = '';
        
        readStream.on('data', chunk => {  
            data += chunk;
        }).on('end', () => {
            resolve(data);
        }).on('error', err => {
            reject(err);
        });
      });  
    }
    
    static create(path, contents) {
        return new Promise((resolve, reject) => {
            fs.writeFile(ABSPATH + path, contents, (err, data) => {
                if(!err) {
                    resolve(data);
                } else {
                    reject(err);
                }
            });
        });
    }

    static remove(path) {
        return new Promise((resolve, reject) => {
            fs.unlink(ABSPATH + path, err => {
                if(!err) {
                    resolve(path);
                } else {
                    reject(err);
                }
            });
        });
    }

    static exists(path) {
        return new Promise((resolve, reject) => {
            fs.access(ABSPATH + path, fs.constants.F_OK, err => {
               if(!err) {
                   resolve(true);
               } else {
                   reject(err);
               }
            });
        });
    }
}

module.exports = Files;

Then we can combine the Promise model with the async/await construct:

'use strict';
const app = require('expresss')();
const Files = require('./lib/Files');

app.get('/api/file', async (req, res) => {
    try {
        let data = await Files.read('/log/file.log');
        res.send(data);
    } catch(err) {
        res.sendStatus(500);
    }
});
app.listen(8000);

As you can see, through the combined use of the Promise and the async/await construct we avoided the callback hell also thanks to a careful separation and delegation of tasks in our code.

Conclusions

We have seen how the Node.js Event Loop is based on asynchronous operations and how such asynchronicity can be preserved without breaking the Event Loop.