Skip to content

14 Linting Rules To Help You Write Asynchronous Code in JavaScript

7 min read

Debugging asynchronous code in JavaScript can feel like navigating a minefield at times. You don't know when and where the console.logs will print out, and you have no idea how your code is executed.

It's hard to correctly structure async code so it executes in the right order as you intend it to.

Wouldn't it be nice if you had some guidance while writing asynchronous code, and to get a helpful message when you're about to make a mistake?

Luckily we have linters to catch some of our bugs before we push them to production. The following is a compiled list of linting rules to specifically help you with writing asynchronous code in JavaScript and Node.js.

Even if you end up not using the rules in your project, reading their descriptions will lead to a better understanding of async code and improve your developer skills.

ESLint rules for asynchronous code

The following rules are shipped by default with ESLint. Enable them by adding them to your .eslintrc configuration file.

1. no-async-promise-executor

This rule disallows passing an async function to the new Promise constructor.

// ❌
new Promise(async (resolve, reject) => {});

// ✅
new Promise((resolve, reject) => {});

While it's technically valid to pass an asynchronous function to the Promise constructor, doing so is usually a mistake for any of these two reasons. First, if the async function throws, the error will be lost and won't be rejected by the newly-constructed promise. Second, if await is used inside the constructor function, the wrapping Promise might be unnecessary and you could remove it.

2. no-await-in-loop

This rule disallows using await inside loops.

When doing an operation on each element of an iterable and awaiting an asynchronous task, it's oftentimes an indication that the program is not taking full advantage of JavaScript's event-driven architecture. By executing the tasks in parallel instead, you could greatly improve the efficiency of your code.

// ❌
for (const url of urls) {
  const response = await fetch(url);
}

// ✅
const responses = [];
for (const url of urls) {
  const response = fetch(url);
  responses.push(response);
}

await Promise.all(responses);

In the odd case when you deliberately want to run tasks in sequence, I recommend temporarily disabling this rule with an inline comment: // eslint-disable-line no-await-in-loop.

3. no-promise-executor-return

This rule disallows returning a value inside a Promise constructor.

// ❌
new Promise((resolve, reject) => {
  return result;
});

// ✅
new Promise((resolve, reject) => {
  resolve(result);
});

Values returned inside a Promise constructor cannot be used and don't affect the promise in any way. The value should be passed to resolve instead, or if an error occurred, call reject with the error.

4. require-atomic-updates

This rule disallows assignments in combination with await, which can lead to race conditions.

Consider the example below, what do you think the final value of totalPosts will be?

// ❌
let totalPosts = 0;

async function getPosts(userId) {
  const users = [{ id: 1, posts: 5 }, { id: 2, posts: 3 }];
  await sleep(Math.random() * 1000);
  return users.find((user) => user.id === userId).posts;
}

async function addPosts(userId) {
  totalPosts += await getPosts(userId);
}

await Promise.all([addPosts(1), addPosts(2)]);
console.log('Post count:', totalPosts);

Perhaps you've sensed that this was a trick question and the answer is not 8. That's right, totalPosts prints either 5 or 3. Go ahead and try it yourself in the browser.

The issue lies in the fact that there is a time gap between reading and updating totalPosts. This causes a race condition such that when the value is updated in a separate function call, the update is not reflected in the current function scope. Therefore both functions add their result to totalPosts's initial value of 0.

To avoid this race condition you should make sure the variable is read at the same time it's updated.

// ✅
let totalPosts = 0;

async function getPosts(userId) {
  const users = [{ id: 1, posts: 5 }, { id: 2, posts: 3 }];
  await sleep(Math.random() * 1000);
  return users.find((user) => user.id === userId).posts;
}

async function addPosts(userId) {
  const posts = await getPosts(userId);
  totalPosts += posts; // variable is read and immediately updated
}

await Promise.all([addPosts(1), addPosts(2)]);
console.log('Post count:', totalPosts);

5. max-nested-callbacks

This rule enforces a maximum nesting depth for callbacks. In other words, this rule prevents callback hell!

/* eslint max-nested-callbacks: ["error", 3] */

// ❌
async1((err, result1) => {
  async2(result1, (err, result2) => {
    async3(result2, (err, result3) => {
      async4(result3, (err, result4) => {
        console.log(result4);
      });
    });
  });
});

// ✅
const result1 = await asyncPromise1();
const result2 = await asyncPromise2(result1);
const result3 = await asyncPromise3(result2);
const result4 = await asyncPromise4(result3);
console.log(result4);

A deep level of nesting makes code hard to read and more difficult to maintain. Refactor callbacks to promises and use modern async/await syntax when writing asynchronous code JavaScript.

6. no-return-await

This rule disallows unnecessary return await.

// ❌
async () => {
  return await getUser(userId);
}

// ✅
async () => {
  return getUser(userId);
}

Awaiting a promise and immediately returning it is unnecessary since all values returned from an async function are wrapped in a promise. Therefore you can return the promise directly.

An exception to this rule is when there is a surrounding try...catch statement. Removing the await keyword will cause a promise rejection to not be caught. In this case, I suggest you assign the result to a variable on a different line to make the intent clear.

// 👎
async () => {
  try {
    return await getUser(userId);
  } catch (error) {
    // Handle getUser error
  }
}

// 👍
async () => {
  try {
    const user = await getUser(userId);
    return user;
  } catch (error) {
    // Handle getUser error
  }
}

7. prefer-promise-reject-errors

This rule enforces using an Error object when rejecting a Promise.

// ❌
Promise.reject('An error occurred');

// ✅
Promise.reject(new Error('An error occurred'));

It's best practice to always reject a Promise with an Error object. Doing so will make it easier to trace where the error came from because error objects store a stack trace.

Node.js specific rules

The following rules are additional ESLint rules for Node.js provided by the eslint-plugin-node plugin. To use them, you need to install and add the plugin to the plugins array in your .eslintrc configuration file.

8. node/handle-callback-err

This rule enforces error handling inside callbacks.

// ❌
function callback(err, data) {
  console.log(data);
}

// ✅
function callback(err, data) {
  if (err) {
    console.log(err);
    return;
  }

  console.log(data);
}

In Node.js it's common to pass the error as the first parameter to a callback function. Forgetting to handle the error can result in your application behaving strangely.

This rule is triggered when the first argument of a function is named err. In large projects, it's not uncommon to find different naming variations for errors such as e or error. You can change the default configuration by providing a second argument to the rule in your .eslintrc file: node/handle-callback-err: ["error", "^(e|err|error)$"]

9. node/no-callback-literal

This rule enforces that a callback function is called with an Error object as the first parameter. In case there's no error, null or undefined are accepted as well.

// ❌
cb('An error!');
callback(result);

// ✅
cb(new Error('An error!'));
callback(null, result);

This rule makes sure you don't accidentally invoke a callback function with a non-error as the first parameter. According to the error-first callback convention, the first argument of a callback function should be the error or otherwise null or undefined if there's no error.

The rule is triggered only when the function is named cb or callback.

10. node/no-sync

This rule disallows using synchronous methods from the Node.js core API where an asynchronous alternative exists.

// ❌
const file = fs.readFileSync(path);

// ✅
const file = await fs.readFile(path);

Using synchronous methods for I/O operations in Node.js blocks the event loop. In most web applications, you want to use asynchronous methods when doing I/O operations.

In some applications like a CLI utility or a script, using the synchronous method is okay. You can disable this rule at the top of the file with /* eslint-disable node/no-sync */.

Additional rules for TypeScript users

If your project is using TypeScript you're probably familiar with TypeScript ESLint (previously TSLint). The following rules are available to TypeScript projects only because they infer extra context from typing information.

11. @typescript-eslint/await-thenable

This rule disallows awaiting a function or value that is not a Promise.

// ❌
function getValue() {
  return someValue;
}

await getValue();

// ✅
async function getValue() {
  return someValue;
}

await getValue();

While it's valid JavaScript to await a non-Promise value (it will resolve immediately), it's often an indication of a programmer error, such as forgetting to add parentheses to call a function that returns a Promise.

12. @typescript-eslint/no-floating-promises

This rule enforces Promises to have an error handler attached.

// ❌
myPromise()
  .then(() => {});

// ✅
myPromise()
  .then(() => {})
  .catch(() => {});

This rule prevents floating Promises in your codebase. A floating Promise is a Promise that doesn't have any code to handle potential errors.

Always make sure to handle promise rejections otherwise your Node.js server will crash.

13. @typescript-eslint/no-misused-promises

This rule disallows passing a Promise to places that aren't designed to handle them, such as if-conditionals.

// ❌
if (getUserFromDB()) {}

// ✅ 👎
if (await getUserFromDB()) {}

// ✅ 👍
const user = await getUserFromDB();
if (user) {}

This rule prevents you from forgetting to await an async function in a place where it would be easy to miss.

While the rule does allow awaiting inside an if-conditional, I recommend assigning the result to a variable and using the variable in the conditional for improved readability.

14. @typescript-eslint/promise-function-async

This rule enforces Promise-returning functions to be async.

// ❌
function doSomething() {
  return somePromise;
}

// ✅
async function doSomething() {
  return somePromise;
}

A non-async function that returns a promise can be problematic since it can throw an Error object and return a rejected promise. Code is usually not written to handle both scenarios. This rule makes sure a function returns a rejected promise or throws an Error, but never both.

Additionally, it's much easier to navigate a codebase when you know that all functions that return a Promise and are therefore asynchronous, are marked as async.

Enable these rules in your project right now

I've published an ESLint configuration package that you can easily add to your project. It exports the base rules, Node.js specific rules, and TypeScript specific rules separately.

For non-Typescript users

Install the package and its peer dependencies:

npm install --save-dev eslint eslint-config-async eslint-plugin-node

Then in your .eslintrc configuration file add the following:

{
  "plugins": [
    "eslint-plugin-node"
  ],
  "extends": [
    "async",
    "async/node"
  ]
}

For TypeScript users

Install the package and its peer dependencies:

npm install --save-dev eslint eslint-config-async eslint-plugin-node typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin

In your .eslintrc configuration file:

"plugins": [
  "eslint-plugin-node",
  "@typescript-eslint"
],
"extends": [
  "async",
  "async/node",
  "async/typescript"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
  "tsconfigRootDir": "__dirname",
  "project": ["./tsconfig.json"],
};

That's it! Add these linting rules for asynchronous code to your project and fix any issues that show up. You might squash a bug or two! 🐛 🚫

Master Asynchronous JavaScript 🚀

Learn how to write modern and easy-to-read asynchronous code with a FREE 5-day email course.

Through visual graphics you will learn how to decompose async code into individual parts and put them back together using a modern async/await approach. Moreover, with 30+ real-world exercises you'll transform knowledge into a practical skill that will make you a better developer.

Refactoring Callbacks, a FREE 5-day email course. 30+ real-world exercises, a visual guide and 5 days, 5 lessons.

Get Lesson 1 now 👇🏼

You'll also get tips on building scalable Node.js applications about twice a month. I respect your email privacy. Unsubscribe any time.

You might also like

Understanding Async & Await

Async/await can be intimidating. With a little guidance, you can write modern asynchronous JavaScript code that just works.
Read article

Using Callbacks With Async/Await

How do you make a callback-based asynchronous function work nicely with async/await? You promisify it.
Read article

Why You Shouldn't Mix Promise.then() With Async/Await Syntax

Mixing Promise.then() with async/await syntax is a recipe for bugs. Here's why you should avoid it and what to do instead.
Read article