The Hidden Cost of Node.js Flexibility hero

The Hidden Cost of Node.js Flexibility

Development
Terminal window
ReferenceError: someFunction is not defined

That error haunted me for weeks. It showed up intermittently in our Vitest runs - sometimes tests passed, sometimes they didn’t. No code changes between runs. Just… chaos.

After more debugging than I’d like to admit, I found the culprit: circular dependencies.

Node.js Doesn’t Care (Until It Does)

Here’s the thing about Node.js - it’s incredibly permissive. You can create modules and import them in any direction you want. Module A imports Module B? Fine. Module B imports Module A right back? Also fine. No warnings, no errors at build time.

Until your test runner loads modules in a slightly different order and suddenly a function that should exist… doesn’t.

The real gut punch came when I ran madge on our codebase:

Terminal window
npx madge --circular --extensions ts src/

Hundreds. Hundreds of circular dependencies.

The Order Problem

The reason these failures are intermittent comes down to module loading order. Imagine three modules with circular dependencies. When your test runner loads userService.test.ts, it might resolve dependencies in order X→Y→Z. But when it loads billingService.test.ts, those same underlying modules might load in order Y→X→Z.

In a circular dependency, that order difference determines whether an export is defined or undefined at the exact moment it’s accessed. Same code, different execution path, different result.

Why This Happens

It’s not always bad code. Sometimes it’s just… reality.

At nutiliti, we work with bills and houses. Bills belong to houses. But houses also have bills. The domain relationships are genuinely circular. When you’re modeling real-world concepts, clean one-directional dependencies aren’t always possible.

But more often, circular dependencies sneak in through:

Barrel files. Those convenient index.ts files that re-export everything from a directory. Your tsconfig might even auto-suggest the barrel import instead of the direct file. You import from @/features/billing instead of @/features/billing/calculateTotal, and suddenly you’ve pulled in the entire module graph.

Organic growth. Developer A adds a utility to Module X. Developer B needs that utility in Module Y, which Module X already imports. Quick fix: import it anyway. The cycle is born.

Convenience over structure. It’s faster to import what you need from wherever it exists than to think about whether that import makes architectural sense.

But It Works in Production…

If circular dependencies are such a problem, why does the application run fine in production?

Because your production environment is hiding the fragility.

Traditional build tools - a simple tsc compile, legacy bundlers, straightforward Node.js execution - load modules synchronously in a deterministic order. They figure out an order that works and stick with it. Every deploy, same order, same result.

Modern tooling doesn’t give you that safety net. Vitest, built on Vite’s philosophy of native ESM and on-demand module loading, isolates each test file with its own module context. Modules load lazily as tests request them. That isolation means each test can stumble into a different - and potentially broken - loading order.

Vitest became our canary in the coal mine. The intermittent test failures weren’t a bug in the test runner. They were revealing fragility that our production environment happened to hide.

This matters beyond testing. If you want to adopt modern build systems - faster bundlers, better treeshaking, improved hot module replacement - circular dependencies become a blocker. The tools that enable those features also enable the chaos. Addressing circular dependencies isn’t just about fixing flaky tests. It’s about keeping your options open for the future.

What Good Actually Looks Like

I know strict dependency injection is possible because I’ve done it - just not in Node.js.

Sesh is a terminal session manager I built in Go. When I rewrote it for version 2, I used the dependency injection patterns that Go’s ecosystem actively encourages. The architecture has four distinct layers:

CLI Layer (commands, flags, user interaction)
Domain Layer (lister, connector, namer - core business logic)
Integration Layer (tmux, zoxide, git wrappers)
Wrapper Layer (execwrap, oswrap - system abstractions)

Dependencies flow in one direction: down. The CLI layer depends on the domain layer, which depends on integrations, which depend on wrappers. Never the reverse. All instantiation happens in one place, with constructor functions wiring everything together.

The result? Exhaustive mocking. Structured unit tests for every piece of the application. When I need to test the Connector, I inject mock implementations of Tmux and Zoxide. The boundaries are crystal clear.

But here’s the uncomfortable truth: this required a complete rewrite.

Version 1 of Sesh wasn’t architected this way. Getting to this level of structure meant starting over with the pattern in mind from day one. Go’s community expects this approach - interfaces, constructor injection, clear layer boundaries. The language and tooling reinforce it.

Node.js doesn’t.

The Gap Between Ideal and Reality

What I learned from Sesh is that other communities do care about this. They have real solutions. But those solutions require either:

  1. Starting with strict patterns from the beginning, or
  2. Committing to a significant rewrite

In a real-world Node.js codebase with multiple developers, competing business goals, and years of organic growth? Expecting that level of organization is… unlikely. Not impossible. But unlikely.

The pressure to ship features will always conflict with the desire for architectural cleanliness. And unlike Go, nothing in the Node.js ecosystem pushes back when you take the convenient path.

The Compounding Problem

Here’s what made this genuinely difficult: I couldn’t just fix it.

With hundreds of existing circular dependencies, I couldn’t enable any tooling that would block new ones. No ESLint rule, no pre-commit hook. Every new PR would fail against issues that predated it.

So while I worked through the backlog with madge, new developers were unknowingly creating more cycles. Two steps forward, one step back.

Tools that can help:

The Actual Fix: Think Top-Down

The solution isn’t a tool. It’s a mental model.

Dependency injection. Understanding which code depends on which. Always passing dependencies down, never reaching back up.

Think of your codebase as a tree. Data flows from trunk to branches to leaves. A leaf can use what the branch provides, but it should never reach back to grab something from the trunk directly.

In practice:

  • Core utilities at the bottom, imported by everything, importing nothing domain-specific
  • Domain modules in the middle, importing utilities, exporting to features
  • Features at the top, importing from domains, never imported by them

When you’re about to add an import, ask: “Am I reaching up or down?”

What I Haven’t Solved

I’ll be honest - the barrel file problem still bites us.

TypeScript’s auto-import suggestions often prefer the index.ts path over the direct file path. It’s more convenient, the import looks cleaner. But it pulls in more than you need and dramatically increases cycle risk.

I don’t have a great answer yet. We’re experimenting with being more intentional about what barrel files export, but it’s friction against developer convenience.

Some problems don’t have clean solutions. You just… manage them.

The Takeaway

Node.js gives you freedom. That freedom has a cost.

If your tests are failing intermittently with bizarre “not defined” errors, check for circular dependencies. If you find some, assume there are more. And if you’re starting a new project, set up detection before the cycles accumulate.

The best time to prevent circular dependencies was at project start. The second best time is now - before that number grows from dozens to hundreds.

Sign-Up for New Posts

Stay in the loop and get the latest blog posts about development sent to your inbox.

Or use the

RSS Feed
man sitting at desk in front of a landscape of rivers leading to a mountain range

Dev Workflow Intro

Your guide to creating a powerful and intuitive development workflow in the terminal.

The terminal is a powerful tool for developers, but it can be overwhelming to know where to start. This guide will help you create a powerful development environment in the terminal. Here are some of the things you'll learn.

  • Install packages and keep them up-to-date
  • Design a minimalist, distraction-free, user-interface
  • Use familiar keyboard shortcuts
  • Manage multiple projects with ease
  • Integrate with Git and GitHub
Get Started