Fixing Circular Imports Introduced by Barrel Files
The error names a variable that is obviously defined:
Uncaught ReferenceError: Cannot access 'DEFAULT_LOCALE' before initialization
at Module../src/format/date.js (index-4f2a91.js:1:88214)
at __webpack_require__ (runtime.js:1:142)
DEFAULT_LOCALE is exported from src/config/constants.js, a file with no imports of its own. The build log explains why it is nonetheless unavailable:
[!] Circular dependency:
src/format/date.js β src/config/index.js β src/config/locale.js β src/format/index.js β src/format/date.js
Four modules in the loop, and two of them are barrels β index.js files whose only content is re-exports. Neither date.js nor locale.js imports the other; the barrels created the edges between them.
Root cause: a barrel makes every module depend on every module
A barrel re-exports everything in a directory:
// src/format/index.js
export * from './date';
export * from './number';
export * from './currency';Any module importing anything from that barrel now depends on all of it. When a module inside the package imports a sibling through the packageβs own index β a natural thing to write, since the index is the documented import path β it creates a loop: the module depends on the index, and the index depends on the module.
The payload consequences of barrels are covered in refactoring barrel files to reduce bundle bloat. Cycles are the correctness consequence of the same structure, and they are more disruptive: a bloated bundle is slow, while an uninitialised binding is a blank page.
Why the error is intermittent
A cycle is not an error by itself. The module system evaluates modules in dependency order and wires live bindings between them; a cycle simply means one module starts evaluating before another has finished. Reading a binding inside a function is safe, because by the time the function runs, everything has initialised.
The error appears only when a module reads a cyclic binding at module scope:
// format/date.js
import { DEFAULT_LOCALE } from '../config';
// Module scope: runs during evaluation. If config hasn't finished
// initialising because we are inside a cycle, this throws.
const formatter = new Intl.DateTimeFormat(DEFAULT_LOCALE);
// Function scope: runs later, when everything is initialised. Always safe.
export function formatDate(value) {
return new Intl.DateTimeFormat(DEFAULT_LOCALE).format(value);
}This is why a cycle that has existed for months starts failing after an unrelated import is added: the new edge changed the evaluation order, and a module that used to be initialised first no longer is.
Fix 1: never import from your own barrel
The rule is mechanical and removes most cycles: within a package, modules import siblings by path; only consumers outside the package import the index.
// src/format/date.js
// Wrong: goes out to the package index and back in, closing a loop.
// import { DEFAULT_LOCALE } from '../config';
// Right: reaches exactly the module that has the value.
import { DEFAULT_LOCALE } from '../config/constants';This is enforceable rather than merely agreed:
// eslint.config.js β block intra-package barrel imports
export default [
{
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['../*/index', '../*/index.js', '..', '../..'],
message: 'Import the sibling module directly β barrel imports inside a package create cycles.',
}],
}],
},
},
];Fix 2: extract shared values to a leaf module
When two modules genuinely need the same value, the cycle is a signal that the value belongs to neither. Move it to a module that imports nothing from either side.
A leaf module has no outgoing edges, so it can never participate in a cycle, and it always initialises first. Constants, enums, type declarations, and configuration objects belong there. The same restructuring is described from the payload side in replacing barrel exports with direct module imports.
Fix 3: fail the build on new cycles
// vite.config.js β Vite 5+: surface cycles instead of warning quietly
export default {
build: {
rollupOptions: {
onwarn(warning, warn) {
// A cycle that only warns will be reintroduced within a quarter.
if (warning.code === 'CIRCULAR_DEPENDENCY') {
throw new Error(`Circular dependency: ${warning.message}`);
}
warn(warning);
},
},
},
};Webpack reports cycles through a plugin rather than natively, but the principle is the same: treat a new cycle as a build failure, because the alternative is discovering it as an uninitialised-binding error in production after an unrelated change reorders the graph.
Step-by-step verification
-
Read the reported path. The bundler prints the full loop. Find the edge that passes through an
indexfile β that is the one to remove. -
Replace one edge and rebuild. Breaking a single barrel import usually resolves the entire cycle; there is no need to restructure the package.
-
Confirm the warning is gone. A cycle report that persists after the change means a second loop exists, often through a different barrel.
-
Check for module-scope reads. Search the previously cyclic modules for top-level statements that consume imported bindings, and defer them into functions or initialisers.
-
Verify the bundle shrank too. Removing intra-package barrel imports usually reduces payload as well, since fewer modules are pulled in transitively.
-
Enable the CI check. Confirm that adding a deliberate cycle now fails the build.
Edge cases and gotchas
Type-only imports. In TypeScript, a type-only import erases at compile time and cannot create a runtime cycle β unless it is written as a value import. Use the explicit type-only form so the erasure is guaranteed.
Cycles only in the production build. Development servers evaluate modules on demand in a different order, so a cycle can be harmless in development and fatal in a built bundle.
Framework-generated barrels. Some code generators emit an index per directory automatically. Those barrels are the same hazard; exclude generated directories from the lint rule only after confirming nothing inside them imports the index.
Test files closing the loop. A test importing from a barrel can create a cycle that only exists in the test graph, producing failures that do not reproduce in the application.
FAQ
Why does importing from my own package index create a cycle?
Because the index re-exports every module in the package, including the one doing the importing. A module that reaches a sibling through the index depends on the index, and the index depends on that module, so the graph contains a loop even though the two modules involved have no direct relationship. Importing the sibling by its own path removes the loop entirely.
Are circular imports always a bug?
Not always, but they are always a risk. A cycle is harmless while every access to a cyclic binding happens after all modules have finished evaluating β inside a function body, for instance. It becomes a runtime error the moment something reads a cyclic binding at module scope, and which module evaluates first depends on graph order, so an innocuous cycle can start throwing after an unrelated import is added.
Does the bundler resolve cycles for me?
It handles them the way the module specification requires, which means it wires up live bindings and evaluates modules in dependency order β not that it makes them safe. The uninitialised-binding error is the specification working correctly: the binding exists but has no value yet. Bundlers warn about cycles precisely because they cannot fix the ordering problem underneath them.
Related
- Refactoring Barrel Files to Reduce Bundle Bloat β the parent guide on what barrels cost
- Replacing Barrel Exports With Direct Module Imports β the payload-side version of the same refactor
- Understanding ES Modules vs CommonJS in Bundlers β how live bindings and evaluation order work
- Component-Level Code Splitting Beyond Routes β why a barrel can also undo a lazy boundary