ReferenceError: Cannot Access 'x' Before Initialization

Why JavaScript throws Cannot access 'x' before initialization, what V8's temporal dead zone actually does, and how to fix and prevent it for good.

JavaScript referenceerror temporal-dead-zone let-const es-modules typescript debugging
Bharath G
Reading Progress

On This Page

Second week of a migration off a legacy config singleton, and I'd split one bloated config.js into two smaller modules that imported each other for a handful of shared constants. Tests green. Staging fine in Chrome. Then a slice of production users on Safari started seeing a blank settings page, and the error in Sentry didn't even have the courtesy to name a variable: ReferenceError: Cannot access uninitialized variable. No identifier, no obvious file. That one cost me most of a day, and it's the reason this article exists.

If you're here because your stack trace says something close to that, or the V8 version with an actual name in it, you're looking at the temporal dead zone, TDZ for short. It's one of those JavaScript mechanics everyone half-remembers from a let vs var explainer and nobody actually understands until it breaks a production build at 2 a.m.

Here's the message, straight from a real Node process, no editing:

/tmp/tdz/basic.js:1
console.log(msg);
            ^

ReferenceError: Cannot access 'msg' before initialization
    at Object.<anonymous> (/tmp/tdz/basic.js:1:13)
    at Module._compile (node:internal/modules/cjs/loader:1705:14)
    at Object..js (node:internal/modules/cjs/loader:1838:10)
    at Module.load (node:internal/modules/cjs/loader:1441:32)
    at Function._load (node:internal/modules/cjs/loader:1263:12)
    at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
    at node:internal/main/run_main_module:36:49

Node.js v22.22.2

produced from:

javascript

console.log(msg);
const msg = "hi";

That's V8 talking. Chrome, Edge, and Node all run V8, so they all print exactly this. The other two major engines say something different for the identical bug, and this is where a lot of people lose an hour thinking they've found a new error:

Firefox (SpiderMonkey):
ReferenceError: can't access lexical declaration 'msg' before initialization

Safari (JavaScriptCore):
ReferenceError: Cannot access uninitialized variable.

Safari's version is the mean one. It drops the variable name entirely. If your bug report came from an iPhone user, that's why you're squinting at a message with nothing to grep for.

Reproducing the temporal dead zone in six lines

You don't need a framework or a bundler to see this. Save this as repro.js and run it with plain node:

javascript

// repro.js
console.log(msg);
const msg = "hi";

bash

$ node repro.js

That's the whole repro. Swap const for let and it behaves identically. This has nothing to do with mutability, only with the scoping rule let, const, and class all share. Swap it for var instead and watch the difference:

javascript

console.log(msg);
var msg = "hi";

bash

$ node var-compare.js
undefined

No crash. var hoists the declaration and initializes it to undefined immediately. let/const/class hoist the declaration but not the initialization: the name exists in scope from the top of the block, but touching it throws until the line that actually assigns it has run. That gap between "the name exists" and "the name is usable" is the dead zone, and it's temporal because it's about when code executes, not where it's written. A function defined above a let can safely reference that let, as long as the function isn't called until after the declaration ran:

javascript

function f() {
  console.log(x); // fine, by the time this runs x is set
}
let x = 1;
f();

Runs clean. The textual position of console.log(x) above let x = 1 doesn't matter one bit.

Does the browser you're in change the wording?

Yes, and it's worth a table because the wording is the only thing that varies; the underlying rule (ES2015, unchanged since) doesn't.

EngineWhere you'll see itExact wordingNames the variable?
V8Chrome, Edge, Node.js (all versions)ReferenceError: Cannot access 'x' before initializationYes
SpiderMonkeyFirefoxReferenceError: can't access lexical declaration 'x' before initializationYes
JavaScriptCoreSafari, iOS WebViewsReferenceError: Cannot access uninitialized variable.No

Node doesn't get its own row because Node's row is the V8 row: there's no version of Node where this behaves differently, because it isn't a runtime feature, it's a language rule V8 has enforced since let/const shipped. I checked this against a fresh Node 22.22.2 install rather than relying on memory, and there's genuinely nothing version-specific to report. No flag changes it, no --harmony variant softens it, and none of the last several major Node releases (22, the current Maintenance LTS through April 2027; 24, Active LTS through April 2028; 26, Current since May 2026) touch this behavior at all. If your error only shows up after a Node upgrade, the upgrade isn't the cause. It changed something else nearby, usually a bundler or transpiler target shipped alongside it.

Bundlers are the real variable here, not runtimes. If your build's target is old enough to downlevel let/const to var (a legacy Babel preset-env target, an old tsconfig target: "es5"), the TDZ disappears in the compiled output because there's no more let, just var with a different name. That's not a fix, it's erasing the guard rail. The bug that would've thrown loudly in dev now runs quietly wrong in production instead.

Why the binding exists before it's usable

Short version: JavaScript still has to know a name belongs to a scope before it can catch you shadowing it, using it early, or reassigning a const. So let, const, and class bindings get created (allocated a slot) the moment their enclosing scope is entered, same as var. What doesn't happen yet is initialization. The slot exists; it just isn't holding a value you're allowed to read.

That's the whole mechanism, and it's also why typeof betrays people who trust it too much. Normally typeof someUndeclaredName is the one safe way to probe for a variable that might not exist: it returns "undefined" instead of throwing:

bash

$ node typeof-undeclared.js
undefined

But typeof on a name that's merely uninitialized still throws, because the binding does exist; the engine just refuses to hand you anything out of it:

javascript

console.log(typeof msg);
let msg = "hi";

bash

$ node typeof.js
ReferenceError: Cannot access 'msg' before initialization

That inconsistency trips people constantly, because for a decade typeof was the guard you reached for specifically to avoid a ReferenceError.

What V8 does with "the hole"

V8's internal name for an uninitialized lexical binding is, literally, "the hole," the same sentinel value it uses to represent a missing element in a sparse array ([1, , 3]). When V8 enters a block containing let, const, or class declarations, it reserves storage for each one (on the stack for a simple function-local case, in a context object when a closure captures it) and fills that storage with the hole, not undefined. Every read of that binding checks first: is this still the hole? If yes, throw ReferenceError before the value ever reaches your code. The line where you actually write let msg = "hi" is the only thing that replaces the hole with a real value, and from that point on the check always passes.

var skips all of this. Its storage is initialized to real undefined at hoist time, which is exactly why reading a hoisted-but-not-yet-assigned var gives you undefined instead of an exception. There's no hole to trip on.

This also explains the difference between two error messages that look similar but mean different things, and knowing which one you've got saves real time:

bash

$ node -e "console.log(neverDeclaredAnywhere)"
ReferenceError: neverDeclaredAnywhere is not defined

versus

bash

$ node basic.js
ReferenceError: Cannot access 'msg' before initialization

"Is not defined" means there's no binding anywhere in scope: no hole, no slot, nothing, usually a typo or a missing import. "Before initialization" means the binding is real and later in the same scope; you just got to it too early. They're diagnosing completely different bugs and the message tells you which one before you've looked at a single other line of code.

Two wrong theories, then the tell

First theory, because the Safari message gave me nothing to go on: I assumed it was a minifier renaming bug. Terser or esbuild mangling two different variables down to colliding short names is a real, if rare, class of bug, and it fit the profile: worked in dev, broke only in the minified production bundle. I turned minification off for a debug build, redeployed to a Safari-only staging slot, and the crash was still there with the real, unmangled names in the stack. Not a naming collision. Dropped it after about forty minutes.

Second theory: a broken build artifact, some circular-dependency edge case in the bundler silently dropping an export so the import came through as undefined further down the chain. I audited the network tab, diffed the shipped bundle against a local build, confirmed every export was present and exactly where it should be. Nothing missing. That one cost the better part of two hours, mostly because I kept re-reading bundle output instead of just isolating the code.

The thing that actually cracked it: I stopped debugging inside the bundler entirely and pulled the two config modules into a folder by themselves, ran them with plain node, and let V8's much more honest error message do the work Safari's wouldn't:

javascript

// a.mjs
import { b } from "./b.mjs";
export const a = 2;
console.log("a.mjs saw b =", b);

javascript

// b.mjs
import { a } from "./a.mjs";
console.log("b.mjs saw a =", a);
export const b = 1;

bash

$ node a.mjs
file:///tmp/tdz/b.mjs:2
console.log("b.mjs saw a =", a);
                             ^

ReferenceError: Cannot access 'a' before initialization
    at file:///tmp/tdz/b.mjs:2:30
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)

Named the exact variable, the exact file, the exact line. Turns out my two "small" config modules imported each other, and the second module tried to read a top-level export from the first one before the first module had finished running: a circular import, not a bundler bug or a missing file at all. Chrome's dev-mode module loader happened to evaluate things in an order where the read landed after the write; the production bundler's concatenated, single-scope output didn't, and neither did Safari's.

If you're chasing your own copy of this, walk it in this order rather than guessing:

bash

# 1. Isolate from the framework and bundler entirely.
#    If a plain `node yourfile.js` (or .mjs) reproduces it,
#    you're dealing with the language, not your build tooling.
node repro.js

# 2. Check the exact wording. "is not defined" and
#    "before initialization" are different bugs -- don't
#    treat them as the same investigation.

# 3. Confirm it's really TDZ and not "eh, it's fine, it's just undefined":
console.log(typeof suspectVariable)
# throws -> TDZ. returns "undefined" -> a genuinely missing binding instead.

# 4. If two modules are involved, log module entry order:
#    a one-line console.log at the top of each file will show you
#    which module's top-level code ran, and how far, before the
#    other one tried to read from it.

Ranked by how often each one is actually the cause, worst offender first: plain accidental use-before-declare after a refactor (by far the most common: someone moved a block, moved a let, or converted var to const without checking order); circular imports between ES modules, especially once a codebase grows past three or four files that all import a shared "constants" or "models" module from each other; and production-only crashes from a bundler reordering or tree-shaking top-level code in ways dev mode's per-module evaluation never surfaces. Further down the list: the classic switch statement footgun; class static-field or static-block initializers reading a sibling static or an outer const that hasn't run yet; and, rarest of all in real codebases, a default parameter reaching for a parameter declared later in the same list. Mine was the second one wearing the disguise of the third.

Four ways to make it stop

The direct fix is always reordering: put the declaration before every path that can reach the read. It sounds too simple to be the actual advice, and for the plain accidental case, it is:

javascript

// before
function f(a = b, b = 2) { console.log(a, b); }
f(); // ReferenceError: Cannot access 'b' before initialization

// after
function f(a, b = 2) {
  a = a === undefined ? b : a;
  console.log(a, b);
}
f(); // 2 2

For the switch trap, brace every case block so each let gets its own scope instead of sharing the switch's outer block with every other case:

javascript

// before -- one shared block, one `result` binding for the whole switch
switch (n) {
  case 0:
    let result = "zero";
    break;
  case 1:
    result = "one"; // still the same TDZ-guarded binding
    console.log(result);
    break;
}

// after -- each case gets its own block, its own binding
switch (n) {
  case 0: {
    let result = "zero";
    break;
  }
  case 1: {
    let result = "one";
    console.log(result);
    break;
  }
}

For circular imports, reordering doesn't work. There's no single "right" order between two files that need each other. What works is making the cross-module read lazy instead of eager, importing the module as a namespace object and touching the property only when a function actually runs, not while the module is still evaluating top to bottom.

javascript

// a-fixed.mjs
import * as bModule from "./b-fixed.mjs";
export const a = 2;
export function printB() {
  console.log("a-fixed.mjs sees b =", bModule.b); // read deferred until call time
}

javascript

// b-fixed.mjs
import { printB } from "./a-fixed.mjs";
export const b = 1;
printB();

bash

$ node b-fixed.mjs
a-fixed.mjs sees b = 1

Same cycle, same two files needing each other. It works now because nothing reads bModule.b until b's module has already finished running.

For a build-only crash, disable minification on a throwaway build and redeploy to a staging slot before you do anything else. It won't fix the bug, but it turns an unreadable, name-mangled Safari message into one with the real identifier in it, which is most of the battle.

I'd ship the lazy-namespace-read fix over the "just reorder the imports" advice you'll see in most write-ups on this. Reordering two files that need each other is usually impossible without breaking one of them, and even when you can force an order, the next unrelated refactor tends to flip it back and reintroduce the exact same crash somewhere else. Removing the cycle, or deferring the read past module-evaluation time, kills the whole class of bug instead of one instance of it.

Code that can't fall into the TDZ

The pattern that actually prevents this, rather than patching one occurrence, is simple: never let two modules need a value from each other at the top level. One of them can export a function the other calls later — that's the lazy-namespace trick above, generalized as a rule rather than a one-off fix. Or pull the genuinely shared piece into a third, leaf-level module that both of the others import from and neither imports back into. Either way, draw your module graph before you split a big file into smaller ones; a straight line of dependencies never produces this error, and a cycle always eventually will.

For switch, brace every case as a habit, not just the ones that currently declare a variable. The day you add a let to a case that used to be a one-liner is the day this bites, and it won't be obvious from the diff.

For class statics with cross-dependencies, compute the value in a static block or a method called after the class body finishes, rather than in a field initializer that runs top-to-bottom during class construction:

javascript

class Config {
  static base = "/api";
  static full = Config.resolve(); // fine: base is already set by the time this line runs
  static resolve() { return Config.base + "/v2"; }
}

Field initializers run in source order, so as long as anything a later field depends on is declared above it in the same class body, you're safe. The trap is only when the dependency comes from outside the class, in a module that hasn't finished loading yet.

Catching this before it ships

ESLint's no-use-before-define rule, with { "classes": true, "variables": true }, catches the plain accidental case at lint time, before it ever reaches a browser. It won't catch a circular-import case, though: that's a cross-file problem a single-file lint rule structurally can't see, so pair it with import/no-cycle (or run madge --circular / dependency-cruiser in CI) to fail the build the moment two modules start depending on each other.

TypeScript's own checker flags a good chunk of these independently, as TS2448 ("Block-scoped variable used before its declaration"), which fires at compile time rather than waiting for a runtime crash. Worth having strict on for that alone, separate from everything else it buys you.

And if your crash only shows up in a minified production build, put an unminified-but-otherwise-production build into your CI smoke test. It's cheap, and it turns "customer reports a blank page on Safari" into "CI failed with a readable stack trace" a build before it ever reaches anyone real.

I'll admit the limit here honestly: I don't have a local JavaScriptCore build I can throw print statements into, so the Safari wording throughout this piece comes from MDN's error reference and the matching GitHub issues (SvelteKit, Astro) that reported the identical crash, not from a process I ran myself the way I did for every V8 example above. If you've seen it phrased differently on a specific iOS version, that's the one part of this I'd genuinely want to hear about.

What to remember

let, const, and class are hoisted, same as var, but only the binding, not the initialization, and reading an uninitialized binding throws instead of quietly giving you undefined. "Is not defined" and "before initialization" are different diagnoses; don't debug them the same way. typeof is not a safe probe inside the dead zone, only outside it. Production-only crashes from this are almost always a bundler exposing a real ordering bug that dev mode's module loader was quietly hiding, not a new bug the bundler introduced. And when two modules need each other, the fix is never "reorder them." It's "stop needing each other at the top level."

JavaScriptreferenceerrortemporal-dead-zonelet-constes-modulestypescriptdebugging

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments