Eliminate Circular Dependencies from Your JavaScript Project

Circular dependencies (also known as cyclic dependencies) occur when two or more modules reference each other.

This could be a direct reference (A -> B -> A):

 
// file a.ts
import { b } from 'b';
...
export a;

// file b.ts
import { a } from 'a';
...
export b;

or indirect (A -> B -> C -> A):

 
// file a.ts
import { b } from 'b';
...
export a;

// file b.ts
import { c } from 'c';
...
export b;

// file c.ts
import { a } from 'a';
...
export c;

While circular dependencies may not directly result in bugs (they certainly can), they will almost always have unintended consequences. In our project, we were experiencing slow TypeScript type-checking and frequent dev-server “JavaScript heap out of memory” crashes.

Node.js does support circular require/import statements between modules, but it can get messy quickly. In the Node.js docs, it says, “Careful planning is required to allow cyclic module dependencies to work correctly within an application.”

In my experience, the best way to deal with circular dependencies is to avoid them altogether. Circular dependencies are usually an indication of bad code design, and they should be refactored and removed if at all possible.

Checking for Circular Dependencies

While there are quite a few Node packages that perform static analysis to look for circular dependencies, they didn’t quite do the trick. Some of the packages found a few circular dependencies, while others missed all of them completely. The best circular dependency checker that I found works at the bundling layer. This webpack circular-dependency-plugin was quite comprehensive and very simple to use.

To get started, I just copied the sample code from the circular-dependency-plugin docs:

 
// webpack.config.js
const CircularDependencyPlugin = require('circular-dependency-plugin')

module.exports = {
  entry: "./src/index",
  plugins: [
    new CircularDependencyPlugin({
      // exclude detection of files based on a RegExp
      exclude: /a\.js|node_modules/,
      // add errors to webpack instead of warnings
      failOnError: true,
      // allow import cycles that include an asyncronous import,
      // e.g. via import(/* webpackMode: "weak" */ './file.js')
      allowAsyncCycles: false,
      // set the current working directory for displaying module paths
      cwd: process.cwd(),
    })
  ]
}

Immediately, the plugin found all sorts of circular dependencies that had been introduced over the duration of the project.

Output of the circular dependency plugin
Output of the circular dependency plugin

Fixing the Circular Dependencies

There are a couple of options to get rid of circular dependencies. For a longer chain, A -> B -> C -> D -> A, if one of the references is removed (for instance, the D -> A reference), the cyclic reference pattern is broken, as well.

For simpler patterns, such as A -> B -> A, refactoring may be necessary. Perhaps the modules that live in B could be moved to A. Or, necessary code could be extracted to a C that both A and B reference. If the two modules perform similar behaviors, they could also be combined into a single module.

Fixing a large number of circular dependencies might be a significant time commitment, but it improves the maintainability of the codebase and can reduce bugs in the future. By leaving the circular dependency plugin in the webpack pipeline, it can be run frequently, and circular dependencies will be found immediately after introducing one.

The next time I’m starting a project and configuring webpack options, I’ll include the circular-dependency-checker from day one.