Cannot access 'Player' before initialization: ESM cycle
Find the import loop behind the ReferenceError with madge, then cut the one edge that points from the base class back to its subclasses.
TL;DR#
ReferenceError: Cannot access 'Player' before initialization on an extends line means two modules import each other, directly or through a chain, and Node.js evaluated the subclass module before the module that declares the base class had finished running. A class declaration cannot be read before its line has executed, so extends Player throws. The fix is to break the cycle: the base class module must not import, even indirectly, a module that imports the subclass. Run madge --circular to find the loop, then move the code that needs the other side (here, a service lookup) out of the base class.
The error, as Node.js 24 prints it for a minimal graph shaped like the question's:
file:///app/broken/src/games/game-player.js:3
export default class GamePlayer extends Player {
^
ReferenceError: Cannot access 'Player' before initialization
at file:///app/broken/src/games/game-player.js:3:41Where this error comes from#
The Stack Overflow question behind this page shows a game server using native ES modules, started through nodemon with the old --experimental-modules flag. The crash pointed at export default class GamePlayer extends Player. The Player base class imported three services at the top of its file (games, players and queueing), and used them only inside methods such as inGame(). The accepted answer found the cause after someone went through the project: "just circular dependencies", an instance "where two modules pointed at each other".
The question's code is truncated, so which file closes the loop is not visible. The reproduction below uses the smallest graph with the same shape: the base class imports a service, the service imports the subclass, and the subclass extends the base class.
Why it happens: evaluation order in a cycle#
An ES module graph is loaded in two phases. First every import is linked, which creates the bindings without running any code; then the modules are evaluated, dependencies first. MDN's guide on cyclic imports explains that cycles do not always fail: an imported binding is only read when it is used, and a ReferenceError is thrown only if it is still uninitialised at that moment.
Follow the evaluation in the broken graph, entered from src/index.js:
index.jsimportsplayer.js, soplayer.jshas to run first.player.jsimportsgames.service.js, which has to run before it.games.service.jsimportsgame-player.js, which has to run before that.game-player.jsimportsplayer.js. That module is already on the stack (step 1), so it is not run again.game-player.jsis evaluated now, whileplayer.jshas not executed a single line.class GamePlayer extends PlayerreadsPlayerimmediately. Its binding exists but is not initialised yet.
The last step is what makes classes special. MDN's class reference states that "class declarations can only be accessed after the place of declaration is reached", which is why they are commonly regarded as non-hoisted, unlike function declarations. The MDN error page gives the V8 wording, Cannot access 'X' before initialization, and notes that it can also occur in cyclic imports. The V8 message template is still "Cannot access '%' before initialization" in the current source.
Two consequences surprise people. The method-level uses of a service are harmless: inGame() only reads GamesService when it is called, long after every module has finished. And the stack trace names the module that read too early, not the import that created the loop, so the file in the trace is rarely the file you need to change.
Fix: cut the import that closes the loop#
The outputs below come from a throwaway node:24-alpine container (Node.js v24.21.0, 7 October 2026). In that run, madge 8.0.0 was installed in a separate folder and its binary called directly; the commands shown are the equivalent for your own project. The quoted output keeps the order in which madge wrote its progress and result lines to the log.
1. Find the cycle with madge#
madge builds the import graph and lists cycles. Install it without recording it in package.json, then point it at your source folder:
npm install --no-save madge
npx madge --circular src/On the broken graph it prints the loop, in import order:
- Finding files
Processed 5 files (443ms)
✖ Found 1 circular dependency!
1) games/game-player.js > players/player.js > games/games.service.js
exit=1Read it as a chain that returns to its first entry: game-player.js imports player.js, which imports games.service.js, which imports game-player.js again. madge exits with status 1 when it finds a cycle, so the same command can guard CI.
2. Decide which edge to remove#
Only one edge in that chain is needed at evaluation time: the subclass reading its base class in extends. That one has to stay. The edge that should go is player.js → games.service.js, because a base class has no reason to know about a service that builds its subclasses. MDN lists the usual ways to remove a cycle: merge the two modules, move the shared code into a third module, or move code from one module to the other.
3. Move the lookup into the service#
Here the base class only imported the service for inGame(). Move that question to the service, which already knows about games, and the base class imports nothing. The verified run used exactly these two files, unchanged game-player.js, and an entry file that calls GamesService.isInGame(p) instead of p.inGame():
// src/players/player.js
export default class Player {
constructor(data) {
Object.assign(this, data);
}
}// src/games/games.service.js
import GamePlayer from './game-player.js';
const games = [];
export default {
getActiveGameByPlayer(player) {
return games.find((g) => g.players.includes(player.id));
},
isInGame(player) {
return this.getActiveGameByPlayer(player) != null;
},
wrap(player) {
return new GamePlayer(player);
},
};Callers change from player.inGame() to GamesService.isInGame(player). In the question's project the same treatment applies to every service the base class imports: PlayersService and QueueingService are each one more possible path back to a subclass.
Verify the fix#
Run the entry point and madge again:
node src/index.js
npx madge --circular src/$ node src/index.js
inGame: false seat: 0
exit=0
$ madge --circular src/ (madge 8.0.0 installed in /tools)
- Finding files
✔ No circular dependency found!
Processed 5 files (459ms)
exit=0The application starts and madge reports no cycle (madge printed its result before its progress line in this run). The second check matters more than the first: the program can start cleanly while a cycle is still there, as the next section shows.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Why reordering imports only hides it#
The same broken files start without error when the entry file imports the subclass first:
// src/index-gp-first.js
import GamePlayer from './games/game-player.js';
import Player from './players/player.js';
import GamesService from './games/games.service.js';$ node src/index-gp-first.js
inGame: false seat: 0
exit=0Entering the cycle at game-player.js makes player.js finish first, so Player is initialised when extends reads it. Nothing in the graph changed. The next test file, script or route that imports player.js first brings the ReferenceError back, so when a crash appears from one entry point and not from another, a cycle is worth checking for.
A second control isolates the class as the trigger. With the broken graph kept intact, Player was rewritten as a function declaration with a prototype method, and the program started (inGame: false seat: 0, exit 0). Function declarations are initialised before the module body runs, and MDN's export reference notes that an exported default function declaration is hoisted. This is not a fix worth adopting: it trades a loud error for a cycle that still exists.
The same cycle with require(): a different message#
Under CommonJS the loop does not throw a ReferenceError. Node.js documents in its modules page that with circular require() calls "a module might not have finished executing when it is returned". The same three files written with require() and module.exports fail like this:
TypeError: Class extends value [object Object] is not a constructor or null
at Object.<anonymous> (/app/cjs/game-player.js:2:26)game-player.js received the unfinished exports object of player.js, an empty object, and tried to extend it. Which message you see depends on the module system the loop runs under; the fix, breaking the cycle, is the same. For other errors that come from mixing the two systems, such as a package whose exports map has no entry for require(), see the guide to ERR_PACKAGE_PATH_NOT_EXPORTED.
A lazy import() as a stopgap#
When the base class really needs the service and the refactor has to wait, a dynamic import() inside the method defers the read until the method is called:
export default class Player {
constructor(data) {
Object.assign(this, data);
}
async inGame() {
const { default: GamesService } = await import('../games/games.service.js');
return GamesService.getActiveGameByPlayer(this) != null;
}
}In the reproduction this started cleanly. The cost is that inGame() now returns a promise, so every caller has to await it, and the cycle is still there: madge still reports it, because a dynamic import with a literal path is still an edge in the graph.
$ madge --circular src/
- Finding files
Processed 5 files (410ms)
✖ Found 1 circular dependency!
1) games/game-player.js > players/player.js > games/games.service.js
exit=1The lazy import only moves the read of the service past module evaluation, which is enough to stop the ReferenceError, not to remove the loop. MDN's error page shows the same idea with a deferred read in setTimeout.
Other places the message shows up#
The message is not specific to imports. It is V8's text for any let, const or class binding read before its declaration runs, so two other cases are common:
- Jest mock factories. Jest hoists
jest.mock()calls above the imports. Its ES6 class mocks guide warns that variables starting withmockare allowed in the factory, but it is still up to you to make sure they are initialised in time, and shows amockSoundPlayerexample that throws a ReferenceError. If the test file runs under native ESM and fails earlier withjest is not defined, that is a different problem, covered in ReferenceError: jest is not defined with ES modules. - Plain ordering inside one file. A
constused by a function that is called before theconstline has run gives the same message with no cycle at all.
Do not confuse it with X is not defined, which means the name does not exist in scope at all. That is a different failure, for example localStorage during a server render in Next.js, where the fix is to read it in useEffect. "Cannot access ... before initialization" always means the binding exists and its declaration simply has not executed yet.
Keeping cycles out#
Once the loop is gone, npx madge --circular src/ in CI keeps it gone, since its exit status is non-zero when a cycle exists. Base classes, shared types and constants should sit at the bottom of the graph and import nothing from the modules that build on them. Factories and registries that need to know every subclass belong in their own module, which imports the subclasses and is imported by no one below it. The Stack Overflow answer with the larger graph reached the same arrangement: it moved the knowledge of Thing subclasses out of Thing and into a separate factory. When a stack trace ends on an extends line with this message, look for the import that points from the base class back up the graph, and remove it.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix: ReferenceError: jest is not defined (ESM tests)
Access Jest's mocking object in native ESM tests without confusing it with the test, expect and global names that remain available by default.
Fix Extremely Slow Drizzle IntelliSense on Large Schemas
Drizzle schema files make tsserver grind to a halt on large table sets. Here are the upgrade and cast trick that restore completions.
Fix ESLint 'import/extensions' Definition Not Found
The import/extensions error appears when the eslint-plugin-import package isn't loaded. Register the plugin and extend airbnb-base to resolve it.


