Exploring Child Processes in Node.js

Exploring Child Processes in Node.js

Brief Introduction

Yes, we have to start with the 'boring" stuff.

Node.js is a powerful single-threaded application that runs on V8 JavaScript Engine. As a Node.js developer, it is important to write clean and efficient code that can optimize performance. In this blog post, we will explore the use of child processes to offload heavy functions from the main thread, and how they can be used to run password encryption on a typical Node.js app upon user registration.

Again, more of the boring stuff.

In simple terms, child processes are processes that are created by another process. They can help distribute load across multiple CPUs, enabling them to run in parallel and providing an opportunity for scaling. Node.js provides multiple ways to adopt child processes, including the fork(), exec(), or spawn() functions. For this blog post, we will be adopting the fork() function as it allows for communication between the parent and child process.

The Fun stuff

I'll be creating two separate modules (mother.js & child.js ) to handle my encryption task. Let's start with mother.js below.

mother.js

const { fork } = require('child_process');
// Create an array of child processes that represents the pool. I've maxed this out at 5
const pool = [];
for (let i = 0; i < 5; i++) {
    //fork the child file and pass the environment variables to the child.
  const child = fork('./utils/child', [], { env: process.env });
  pool.push(child);
}

// Export the mother module as a promise for use in your code
module.exports = (params) => {
  return new Promise((resolve, reject) => {
//If no child is available throw an error
    if (pool.length === 0) {
      reject(new Error('No child processes available in the pool'));
      return;
    }
    const child = pool.shift();
    console.log('pool size:', pool.length);//to check the number of child processes available

//send the parameters to the child process
    child.send({
      payload: params.payload,
      activity: params.activity,
    });
  // Listen for a response from the child process 
    child.once('message', resp => {
//Return the child to the pool
      pool.push(child);
      try {
//Check for errors in the child process
        if (resp.type === 'error') {
          throw new Error(resp.data);
        }
//send the complete job data
        resolve(resp.data);
      } catch (error) {
//catch other errors
        reject(error);
      }
    })
  })
}

In the mother.js file above, the fork() function is imported from the child process and used to create 5 children from the parent process when the server is spun up. The child processes are saved in a pool to be used by the exported function. This function accepts parameters that include the payload and the activity. I'll further explain how this works in the child.js file below.

child.js


//My env variable setup. This is dependent on how you've setup your environment variables.
const env = process.env.NODE_ENV || 'development';

//The utils service file that contains the logic for encryting passwords.
const utils = require('../utils/utils')

//This listens for messages
process.on('message', async (message) => {
  try {

    //retrieve the activity and payload from the message object.
    const { activity, payload } = message;

    //A switch statement for scalability, you can add additional activities to be run on a child process
    switch (activity) {
      case 'Hashing':
//This uses the hashing function from the util file to hash the password in the payload
        const hashedpassword = await utils.hashPassword(payload.password);
        process.send({ type: 'success', data: hashedpassword });
        break;
      default:
        throw new Error('Activity not found');
    }

  } catch (error) {
//This sends any error that is raised to the parent process
    process.send({ type: 'error', data: `${error}` });
  }
});

utils.js

const env = process.env.NODE_ENV || 'development';
const config = require(`${__dirname}/../config/config.js`)[env];
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const SALT_ROUNDS = config.SALT_ROUNDS


module.exports = {
// A simple hashing function
     async hashPassword(password) {
        const salt = await bcrypt.genSalt(SALT_ROUNDS);
        console.log('salt', salt);
        return bcrypt.hash(password, salt);;
    }
}

This child.js listens for messages from mother.js process. When it receives a message, it checks for a specific activity type and performs the corresponding action defined for this activity, in this case, Hashing.

The snippet initially sets the environment variable, which is a setting dependent on the developer's configurations.

It then requires a util.js service file that contains a function for encrypting passwords.

The child.js uses a switch statement to identify and handle different activity types, which can be scaled based on different use cases.

If the hashing is successful, the child process sends a message to its parent process with the type 'success' and the hashed password data. Otherwise, if an error occurs during the process, the child process sends a message to its parent process with the type 'error' with the error message data.

user.js

//other import statements omitted
const child_worker = require('../utils/mother.js');

const user = new Schema({
//my schema is defined here

//My presave hook that handles creating a user in my mongo collection.

user.pre('save', async function (next) {
  try {
    // Only run this function if password was actually modified
    if (!this.isModified('password')) return next();

    const [hash] = await Promise.all([
      child_worker({ activity: 'Hashing', payload: { password: this.password } }).catch(err => {
        return null;
      })
    ]);

    this.password = hash ? hash : await bcrypt.hash(this.password, await bcrypt.genSalt(SALT_ROUNDS) );
    this.passwordConfirm = undefined;
    this.passwordChangedAt = Date.now() - 1000;
    next();
  } catch (error) {
    next(error);
  }
});

const UserModel = mongoose.model('User', user);
module.exports = UserModel;

The user.js model file imports the exported function from mother.js. In my pre-save hook, the password and activity are passed to the function as the parameter. The function is expected to return the hashed password, else it falls back to my default hashing process on the main process.

There you go, we just hashed a password using a child process!

It is worth noting that using child processes does not always guarantee optimized performances. It is often dependent on the use case and expected overhead. Some processes are better off outside child processes.

Conclusion

Child processes provide an effective way to offload heavy functions from the main thread and can help optimize performance in Node.js applications. In this blog post, we explored the use of child processes for password encryption and demonstrated how they can be implemented using the fork() function. By adopting child processes, we can improve the scalability and efficiency of our Node.js applications, making them more responsive and reliable.

References

How To Launch Child Processes in Node.js | DigitalOcean
The V8 JavaScript Engine (nodejs.dev)
Node.js Child Processes and Cluster Module: A Guide to High-Performance Applications (voskan.host)