A bug report arrived that defied every debugging instinct. Users on low-end Android phones said the app simply disappeared. Not at launch. Not during a specific tap or swipe. Roughly twenty minutes into a session, the screen froze and the process died. The logs were spotless. QA could not reproduce it on their high-end hardware. There were no steps to follow. After three hours of memory profiling, the picture finally cleared. A single event listener sat inside a React hook. That listener had closed over a large dataset. The component unmounted. The listener stayed. The dataset stayed in memory. On a device with 2GB of RAM, that accumulation exhausted the heap and the operating system killed the app. This was not a syntax error or a logic flaw. It was a scope bug, and it was fatal.

How a Closure Becomes a Leak

Most tutorials teach scope as an academic puzzle about where a variable is visible. In production, scope is a contract about memory lifetime. When a JavaScript function closes over a variable, the engine keeps that variable alive as long as the closure itself can be reached. In a React component, this means your data survives long after the user navigates away and the UI node is gone.

Consider a hook that registers a listener on the window object. The component renders, attaches the listener, and later unmounts. If the cleanup phase is missing or botched, the listener remains. Every fresh mount adds another ghost copy of the trapped data to RAM. On a developer workstation with abundant memory, you might never notice the bloat. On a budget phone running Android Go, twenty minutes of ordinary use is enough to exhaust the available heap. The OS steps in and terminates the process. There is no exception to log. The system simply pulls the plug.

This is why scope is memory management. The lexical environment is not a philosophical boundary. It is a retention graph. Every variable you leave inside an uncollected closure is a brick in a wall that eventually boxes your app in.

Three Ways Scope Kills Production Apps

Scope issues do not all look alike. Some drain memory slowly. Others explode instantly. Here are the patterns that reliably bring apps down.

Global Scope Pollution

Micro-frontend architectures let teams ship independently, but they all share the same window object. When one application sets a global variable like window.config or patches a shared utility onto window, it does not live in isolation. Another team’s app might depend on a different shape for that same global, or overwrite it during its own bootstrap. The result is a feature collision that scales with your organization. A developer in one repository has no idea their shortcut is another team’s breaking change. As the surface area grows, these globals become landmines buried in shared soil.

Closure Memory Leaks

Single page applications are built to run for hours. That durability is exactly why leaking closures become toxic. The pattern is deceptively common: a useEffect registers a callback with a global event bus, a WebSocket handler, or the DOM itself. If the dependency array is unstable or omitted, the cleanup never matches the original subscription. The closure captures whatever is in its lexical scope, which can include massive parsed arrays, fetched JSON blobs, or references to DOM trees. Every navigation adds more weight. The user does not know why their browser tab is pushing 800MB. They only know the app feels sluggish and eventually dies.

This is especially dangerous when dependency arrays change on every render. A new function reference is born each cycle, registered with a listener, and the old one is never released. The result is a museum of dead closures, each hoarding the data they were born with.

TDZ Errors in Dynamic Modules

テンポラル・デッドゾーン(TDZ)は、理論上のエッジケースではありません。letconst の宣言が実行される前にそれらにアクセスすると、エンジンは ReferenceError をスローします。循環参照や動的インポートを含む大規模なモノレポでは、実行順序が暗黙的であることがよくあります。モジュールAがモジュールBをインポートし、モジュールBがモジュールAに依存するチャンクを動的にインポートするといった状況です。もし、あるブランチが初期化の完了していない変数に触れた場合、アプリはロード中にクラッシュします。これらの失敗はタイミングに依存するため、非常に厄介です。バンドラーの分割ポイントのわずかな変更、コード読み込み時のネットワーク遅延、あるいはチャンクのキャッシュの変化によって、TDZを誘発するのに十分なほど順序が変わってしまうことがあります。クラッシュは予測不能であり、スタックトレースは通常、全く問題のないコードの行を指し示します。

防御的な戦術

スコープのバグから救ってもらうために、スタックトレースを頼りにすることはできません。必要なのは予防と検知です。

まずは静的解析から始めましょう。ESLintを設定して、厳格な境界を強制します。no-implicit-globalsno-shadow といったルールは、明らかなミスを捉えてくれます。特にシャドウイングは危険です。なぜなら、実際には外部の変数に対してクロージャを構築していたり、意図せず重複を作成していたりするのに、ローカル変数を変更していると思い込ませてしまうからです。これらのルールは、意図を明示させ、サイレントな衝突を排除します。

ユニットテストに適用するのと同じ規律を持って、メモリのプロファイリングを行ってください。Chrome DevToolsを開き、開始ルートでヒープスナップショットを撮り、アプリケーション内を5分間操作してから、もう一度撮ります。その2つを比較してください。「Closure」でフィルタリングし、際限なく増え続けているカウントを探します。また、イベントリスナーを保持したままの「detached DOM nodes」も探してください。もし、ユーザー数が横ばいであるにもかかわらず、2番目のスナップショットで数千もの新しい Closure エントリが表示されたなら、データを取り込んで離さない関数(trapped functions)が存在することになります。それがメモリリークの正体です。

設計面では、設定を取得するためにグローバルな window オブジェクトに手を出すのをやめましょう。設定は props として、あるいは型定義された context を通じて渡してください。ここでの「Dependency injection」は、単なるエンタープライズ向けのバズワードではありません。関数がグローバルスコープを嗅ぎ回るのではなく、引数を通じて必要なものすべてを受け取るという実践のことです。その結果、ブラウザのシムなしでテスト可能なコードになり、複数のアプリが同じシェル内でマウントされてもモジュール同士が衝突しないようになります。

最後に、クリーンアップフェーズを徹底的に尊重してください。すべての addEventListener には、エフェクトのクリーンアップ内で対応する removeEventListener が必要です。非同期処理については、AbortController を使用し、その signal を fetch に渡すことで、コンポーネントが破棄されたときに実行中のリクエストをキャンセルできるようにします。これらの習慣は、スコープの生存期間を直接制御します。これらは単なるボイラープレートではなく、メモリ管理そのものなのです。

これがチームにとって何を意味するか

スコープは、面接中に候補者を試すための小手先のテクニックではありません。本番環境において、スコープはメモリ管理そのものです。宣言するすべての変数は、潜在的な人質です。すべてのクロージャは、エンジンが守るべき約束です。リスナーの解放を忘れることは、単に電気をつけっぱなしにしているのとは違います。アプリに重りを繋ぎ、それを海に投げ込んでいるようなものです。高性能なハードウェアなら、アプリは何とか泳ぎ続けます。しかし、ローエンドデバイスのユーザーにとっては、アプリは沈没します。スコープを、それがそうであるように、有限のリソースとして扱い始めてください。そうすれば、ユーザーも、そしてあなたの3時間に及ぶデバッグ作業も、感謝することでしょう。