DEV Community

Abhishek Kumar Dutta
Abhishek Kumar Dutta

Posted on

The Scope Bug That Crashes Your App 20 Minutes After the User Stops Touching It


Scope mismanagement is not a junior mistake — it is a source of hidden architectural debt that scales linearly with application complexity.

The bug report said the app was crashing on mobile. Not on load, not on a specific interaction — just randomly, about twenty minutes into a session, on low-end Android devices. No reproducible steps. Clean error logs. The kind of bug that makes engineers distrust their own tools.

The actual cause, found after three hours of memory profiling, was a single event listener registered inside a React hook. The listener captured a reference to a large dataset in its closure. The component unmounted. The listener did not. The dataset stayed in memory. On a device with 2GB of RAM shared across a browser session and several other apps, the accumulation across twenty minutes of navigation was enough to exhaust the available heap.

The engineer who wrote the hook understood closures. They could have explained lexical scoping in an interview. What they did not have was the instinct to think about how long a scope stays alive in memory, and in a long-lived SPA session, that instinct is what separates a functioning application from one that fails silently on your users' most constrained devices.

This post is not about var versus let. It is about what scope and hoisting actually cost in production architectures in 2026 — across micro-frontends, serverless edge runtimes, and SPA sessions that last hours.

The mechanism: what scope and hoisting actually do at runtime

Scope determines which variables are visible where and for how long they stay in memory. Hoisting determines when variable bindings become available for execution. Both of these are taught as syntax mechanics in introductory JavaScript, and both of them have runtime consequences that introductory material almost never covers.

Lexical scope and memory lifetime are the pairing that matters most in production. When a function closes over a variable from an outer scope, the JavaScript engine cannot garbage-collect that variable as long as the closure itself is reachable. This is correct and intentional — closures are supposed to work this way. The problem is that in component-based architectures, closures frequently outlive the components that created them.

function useDataProcessor(dataset) {
  useEffect(() => {
    // This closure captures `dataset` — potentially megabytes of data
    const handleMessage = (event) => {
      const processed = dataset.find(item => item.id === event.data.id);
      dispatch({ type: 'UPDATE', payload: processed });
    };

    window.addEventListener('message', handleMessage);

    // Missing cleanup — `handleMessage` and `dataset` stay in memory
    // even after this component unmounts
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

The component unmounts. The effect cleanup does not run because no cleanup function was returned. The handleMessage closure, still registered on window, holds a reference to the dataset. The garbage collector cannot touch the dataset because handleMessage is still reachable. Every time this hook mounts and unmounts during navigation, another copy of the dataset accumulates in memory.

Hoisting and the Temporal Dead Zone matter differently. let and const fixed the chaos of var hoisting variables declared with let and const are hoisted to the top of their block, but they are not initialised. The gap between the start of the scope and the declaration line is the Temporal Dead Zone: accessing the variable in that gap throws a ReferenceError.

In local development, TDZ errors are caught immediately. In complex module graphs with circular dependencies and dynamic imports, they surface at runtime in ways that are much harder to trace.

// moduleA.js
import { config } from './moduleB.js';
export const serviceA = new Service(config); // TDZ error if moduleB hasn't initialised config yet

// moduleB.js
import { serviceA } from './moduleA.js';
export const config = { endpoint: serviceA.baseUrl }; // circular — both modules reference each other
Enter fullscreen mode Exit fullscreen mode

In a simple two-file example this is obvious. In a monorepo with hundreds of modules loaded dynamically across several independently deployed micro-frontends, the execution order is implicit rather than explicit — and implicit execution order is where TDZ errors wait.

The real-world cost: three failure modes that appear in production

Global scope pollution in micro-frontend architectures
In a monolithic application bundled by Webpack, global scope was largely managed by the module system. Variables stayed inside their module's closure. The global window object was something you touched deliberately.

Module Federation and independently deployed micro-frontends changed this. Multiple JavaScript applications now run in the same browser session, sharing the same window object. An event listener registered globally by App A is visible to App B. A global variable set by App A can shadow or overwrite a global variable expected by App B.

// App A registers a global handler
window.analyticsReady = true;
window.addEventListener('analytics:track', handleTrack);

// App B assumes analytics isn't ready yet and initialises again
if (!window.analyticsReady) {
  window.analyticsReady = true; // never reached — App A already set this
  window.addEventListener('analytics:track', handleTrack); // duplicate listener
}
Enter fullscreen mode Exit fullscreen mode

This is not a hypothetical. It is the most common class of bug in mature micro-frontend architectures: feature collisions that crash one application because another application's scope management is insufficiently strict. The failure surface is proportional to the number of independently developed teams sharing a browser session, which means it scales exactly as your organization grows.

Closure memory accumulation in long-lived sessions

The twenty-minute crash scenario from the opening is not unusual. SPAs are designed to keep users on a single page for extended sessions, dashboards, admin tools, and data-heavy applications where navigation happens within the app rather than through full page loads. In these contexts, memory management is an active concern, not an afterthought.

The pattern that causes accumulation is always a variation of the same structure: a closure captures a large reference, the closure outlives its intended scope, and the garbage collector cannot reclaim the reference because the closure is still reachable.

// Pattern that leaks
function useRealtimeData(largeDataset) {
  useEffect(() => {
    const subscription = eventBus.subscribe('update', (event) => {
      // `largeDataset` captured here — could be hundreds of MB
      const match = largeDataset.find(item => item.id === event.id);
      if (match) updateState(match);
    });

    return () => subscription.unsubscribe(); // correct cleanup
  }, [largeDataset]); // but `largeDataset` changes on every render — new closure each time
}
Enter fullscreen mode Exit fullscreen mode

The cleanup runs but only for the previous subscription. If largeDataset changes on every render (because it is created inline in the parent component), a new closure is created and subscribed on every render, and the cleanup only unsubscribes the most recent one. Prior subscriptions and their captured references accumulate.

TDZ errors in dynamic module loading

Serverless edge runtimes and dynamically imported modules introduce execution order non-determinism that TDZ errors exploit. The most common pattern is a circular dependency where two modules each import something from the other, and the initialization order depends on which one was requested first.

The defensive pattern is explicit initialization rather than relying on module-level execution order:

// Fragile — depends on execution order
export const config = loadConfig(); // may be undefined if loadConfig isn't hoisted

// Explicit — initialisation is deferred until the function is called
export function getConfig() {
  if (!_config) _config = loadConfig();
  return _config;
}
let _config;
Enter fullscreen mode Exit fullscreen mode

The function version is immune to TDZ errors because loadConfig is only called when getConfig is invoked, by which point all modules have fully initialized. The export version executes at module evaluation time, which may be before its dependencies are ready.

The fix: three enforced patterns for runtime scope safety

Enforce strict global scope boundaries with ESLint
Global assignments should not be possible by accident. Configure ESLint to make them impossible:

{
  "rules": {
    "no-global-assign": "error",
    "no-implicit-globals": "error",
    "no-shadow": "warn"
  }
}
Enter fullscreen mode Exit fullscreen mode

no-shadow is particularly valuable in large codebases; it flags variable declarations that shadow outer scope variables, preventing the class of bug where a local config silently overrides an outer config that other code was relying on.

For micro-frontend architectures specifically, establish a shared namespace convention (window.__appName__) and lint against any global assignments outside that namespace. Treat window as a restricted API that requires explicit review to touch.

Profile closure lifecycles in memory tooling

The Chrome DevTools Memory panel's heap snapshot comparison is the right tool for catching closure accumulation before it reaches production. The workflow:

Take a baseline heap snapshot. Navigate through the application for several minutes, exercising the features most likely to register and unregister listeners. Take a second snapshot. Use the comparison view to find object counts that grew; specifically look for Closure and EventListener entries that increased without a corresponding decrease.

Any closure that survives a component's unmount cycle when it should not is a memory leak waiting to compound. The fix is always the same: return a cleanup function from useEffect that explicitly removes every listener and subscription the effect registered, and audit dependency arrays for references that change identity on every render.

Design for explicit dependency injection

Components and services that reach into outer or global scope for configuration are fragile in concurrent execution environments. React's concurrent renderer can run the same component multiple times, and serverless functions share no state between invocations. Both contexts assume that functions are pure with respect to external state.

// Fragile — reaches into global scope
function DataService() {
  const endpoint = window.__config__.apiEndpoint; // global dependency
  return fetch(endpoint);
}

// Explicit — dependency is injected, no global scope access
function DataService({ config }) {
  return fetch(config.apiEndpoint);
}
Enter fullscreen mode Exit fullscreen mode

Explicit injection makes functions testable in isolation, safe for concurrent execution, and predictable across the different module loading orders that dynamic imports can produce. It also makes dependencies visible at the call site; the next engineer reading the code knows exactly what this function needs to run.

Key takeaway

Scope and hoisting are dismissed as entry-level topics because the interview-level understanding of them is entry-level. The production-level understanding is different: it is about memory lifetime, execution order guarantees, and the boundaries that prevent one part of a large system from corrupting another.

The bugs these concepts produce in mature applications are not syntax errors caught at compile time. They are memory accumulation caught in a user's crash report twenty minutes into their session, global collisions caught when two teams' features start interfering with each other, and TDZ errors caught in a serverless runtime that has no local equivalent.

Mastery here means moving past "I know how closures work" to "I can audit a codebase for closures that outlive their intended scope and predict which ones will cause problems under load." That is a different skill, and it is one that scales in value as the systems you build grow more complex.

What to audit this week

Memory leak audit: Open the DevTools Memory panel on your most data-heavy screen. Take a heap snapshot, navigate away and back several times, take another. Filter the comparison for Closure and EventListener anything that grew and is worth investigating.

Global scope audit:

# Find direct window assignments outside sanctioned namespaces
grep -rn "window\." src/ | grep -v "__appName__" | grep "="
Enter fullscreen mode Exit fullscreen mode

Closure dependency audit: Search your codebase for useEffect calls with empty dependency arrays that register event listeners or subscriptions; these are the highest-risk pattern for the accumulation bug described above.

grep -A5 "useEffect(\(\) =>" src/ | grep -B2 "addEventListener\|subscribe"
Enter fullscreen mode Exit fullscreen mode

Top comments (0)