Frank Mitchell

Use promises in Node.js and avoid callback hell

Callback hell is an easy JavaScript anti-pattern to recognize. There will be a trail of closing braces, parenthesis, and semicolons at the end of your code. For example, here’s a rsync like program written with callbacks.

#!/usr/bin/env node

const fs = require('fs');

const file1 = process.argv[2];
const file2 = process.argv[3];

fs.stat(file1, (err, stats1) => {
  if (err) {
    stats1 = {size: 0, mtime: Date.now()};
  }

  fs.stat(file2, (err, stats2) => {
    if (err) {
      stats2 = {size: 0, mtime: Date.now()};
    }

    if (stats1.size === stats2.size) {
      const time1 = stats1.mtime.getTime();
      const time2 = stats2.mtime.getTime();

      if (time1 <= time2) {
        console.log('sent 0 bytes');
        process.exit(0);
      }
    }

    fs.readFile(file1, (err, data) => {
      if (err) {
        console.error(err);
        process.exit(1);
      }

      fs.writeFile(file2, data, (err) => {
        if (err) {
          console.error(err);
          process.exit(1);
        }

        console.log(`sent ${data.size} bytes`);
      });
    });
  });
});

Notice all the }); characters at the end? That’s callback hell. This code has four logging statements, three early returns, and a cascade of nested functions. Lucky for us, Node supports Promise objects, so there’s a way to refactor this.

The first thing we can tackle is the fs.writeFile call. We log the number of bytes written if the write is successful. We log an error and exit the program if the write fails. This maps nicely onto promises.

function writeFile(path, data) {
  return new Promise((resolve, reject) => {
    fs.writeFile(path, data, (err) => {
      if (err) {
        return reject(err);
      }
      resolve(data.length);
    });
  });
}

Here’s what the business logic of our program looks like when we rewrite it to use our new writeFile function.

fs.stat(file1, (err, stats1) => {
  if (err) {
    stats1 = {size: 0, mtime: Date.now()};
  }

  fs.stat(file2, (err, stats2) => {
    if (err) {
      stats2 = {size: 0, mtime: Date.now()};
    }

    if (stats1.size === stats2.size) {
      const time1 = stats1.mtime.getTime();
      const time2 = stats2.mtime.getTime();

      if (time1 <= time2) {
        console.log('sent 0 bytes');
        process.exit(0);
      }
    }

    fs.readFile(file1, (err, data) => {
      if (err) {
        console.error(err);
        process.exit(1);
      }

      writeFile(file2, data).then(size => {
        console.log(`sent ${size} bytes`);
      }).catch(err => {
        console.log(err);
        process.exit(1);
      });
    });
  });
});

Notice that we didn’t remove any levels of function nesting. That’s because promises take then and catch callbacks. So we’ll always have at least one level of nesting.

To remove those }); bits at the end, we need to take the next step and rewrite the fs.readFile call. Since we return the data if the read’s successful, and log an error if the read fails, converting file reading to use promises is also simple.

function readFile(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, (err, data) => {
      if (err) {
        return reject(err);
      }
      resolve(data);
    });
  });
}

Here’s what the core of our program looks like when we rewrite it to use our new readFile function. There’s now only three levels of function nesting instead of four.

fs.stat(file1, (err, stats1) => {
  if (err) {
    stats1 = {size: 0, mtime: Date.now()};
  }

  fs.stat(file2, (err, stats2) => {
    if (err) {
      stats2 = {size: 0, mtime: Date.now()};
    }

    if (stats1.size === stats2.size) {
      const time1 = stats1.mtime.getTime();
      const time2 = stats2.mtime.getTime();

      if (time1 <= time2) {
        console.log('sent 0 bytes');
        process.exit(0);
      }
    }

    readFile(file1).then(data => {
      return writeFile(file2, data);
    }).then(size => {
      console.log(`sent ${size} bytes`);
    }).catch(err => {
      console.log(err);
      process.exit(1);
    });
  });
});

Plus, we have one error handler instead of two. The catch callback handles the error cases for both reading and writing files. We still have an early return, but we can use Promise.resolve to eliminate that.

Promise.resolve turns a value into a promise. We can use null as a marker for the early return case, where file size is the same and file modification time hasn’t changed. Then we can do an extra check to make sure we have data before trying to write it.

fs.stat(file1, (err, stats1) => {
  if (err) {
    stats1 = {size: 0, mtime: Date.now()};
  }

  fs.stat(file2, (err, stats2) => {
    if (err) {
      stats2 = {size: 0, mtime: Date.now()};
    }

    let promise = readFile(file1);

    if (stats1.size === stats2.size) {
      const time1 = stats1.mtime.getTime();
      const time2 = stats2.mtime.getTime();

      if (time1 <= time2) {
        promise = Promise.resolve(null);
      }
    }

    promise.then(data => {
      if (!data) {
        return 0;
      }

      return writeFile(file2, data);
    }).then(size => {
      console.log(`sent ${size} bytes`);
    }).catch(err => {
      console.log(err);
      process.exit(1);
    });
  });
});

Promises are smart. They’ll ensure anything returned from a then callback is a promise. Even though we sometimes return zero and sometimes return a Promise object, it’s always safe to call then on the result and log the number of bytes written.

The last thing to rewrite is the fs.stat calls . If there’s an error, we pretend that the file is empty and brand new. That covers edge cases where the files we’re working with can’t be accessed or don’t exist.

function stat(path) {
  return new Promise((resolve) => {
    fs.stat(path, (err, result) => {
      if (err) {
        result = {size: 0, mtime: Date.now()};
      }
      resolve(result);
    });
  });
}

Because we’re handling errors, we don’t need a reject callback. If the fs.stat call throws, our promise will bubble the exception up to any catch callback chained onto it. This means we can keep all the error handling code in one place.

We need to call our stat function twice, once for each file. And we need to wait until both calls resolve before doing anything else. Promise.all takes care of that. It waits until both stat calls resolve and returns their results as an array.

Promise.all([stat(file1), stat(file2)]).then(stats => {
  if (stats[0].size === stats[1].size) {
    const time1 = stats[0].mtime.getTime();
    const time2 = stats[1].mtime.getTime();

    if (time1 <= time2) {
      return null;
    }
  }

  return readFile(file1);
}).then(data => {
  if (!data) {
    return 0;
  }

  return writeFile(file2, data);
}).then(size => {
  console.log(`sent ${size} bytes`);
}).catch(err => {
  console.log(err);
  process.exit(1);
});

This refactored code has a single level of function nesting. It keeps logging to a minimum and avoids early returns. We could take it a step further and move stuff like the time comparison logic into its own function. But this is a good stopping point for now.

I tend to work from the inside out when doing this kind of refactoring. That’s because it’s often easy to change an existing callback to return a promise. Going the other way, having a promise call a callback, can get messy. Try both ways and see which you prefer. There’s no wrong way to excape callback hell.

Emails

Want Node.js tips delivered to your inbox? Put your name and email address in the form below.