Patterns to avoid when architecting a single-page application’s dependency graph

When writing a single-page application, an unruly dependency graph can get in the way of performance and hinder testing. Taking care to avoid a couple of key anti-patterns is straightforward and yields long term results.

Circular dependencies

A dependency cycle where modules A, B, and C depend on module U, and U depends on X. X then forms a cycle by depending on C.

Introducing circular dependencies is an easy trap to fall into without automated checks to prevent them from appearing and proliferating in a codebase over time. Architecturally, ensuring a non-cyclic dependency graph is simply a best practice, but it’s necessary to emphasize that in some cases this is more than concern for arbitrary technical purity and actually has distinct consequences.

Much like a reference counting garbage collector can’t release either of two references that are mutually referential, some cyclic dependencies between modules get in the way of code splitting, sometimes impacting large portions of a dependency graph.

In the diagram above, module U depends on module X, which depends on module C. Modules A, B, and C are meant to be entrypoints architecturally, but the dependency back to module C means A and B unnecessarily depend on C. Whatever module X depends on within C, likely a constant or helper function, should be either moved into X or extracted to a separate module that both can depend on, breaking the cycle.

If you have any intent of code splitting an application, whether dynamically or into multiple entrypoints, it’s worthwhile to prevent circular dependencies from day one using a tool like circular-dependency-plugin. Even if your application is small enough that code splitting isn’t necessary, auditing your dependency graph will lead to more isolated modules that are easier to reason about independently, design in isolation, and extract to separate packages for reuse in another project should the need arise.

Import bottlenecks

An import bottleneck where an index module re-exporting several other modules can't be code split into those separate modules.

When multiple modules are re-exported through an index module meant as the public entrypoint to a unit of functionality — effectively serving as a namespace — webpack is capable of tree shaking the inner modules. However, this tree shaking only eliminates unused inner modules; it can’t prune the set of inner modules differently for different parts of the dependency graph. The index places a limit on how optimized the dependency graph can be for different parts of the application.

In the diagram above, module A dynamically imports B and C, which subsequently import X to access various inner modules exposed through it. They might depend on entirely different subsets of X, but the full subset of X depended on by either must be loaded as a dependency of either of these modules.

Although an index module can be thinned out, it can’t be munged into multiple variations of the same module. Index modules full of re-exports aren’t fully transparent, and should be avoided for optimal performance if your application is using dynamic imports. Jason Galea dives into the intersection of tree shaking and code splitting here with more examples of webpack’s output in various scenarios.

or reach out to me at contact@ctidd.com