Chapter 9

Advanced JavaScript

Closures, prototypes, classes, the event loop, iterators and error handling.

9. Advanced JavaScript

JavaScript's familiar syntax sits on top of a runtime with precise rules for scope, object delegation, function calls, asynchronous scheduling, and memory management. Understanding those rules helps explain why a callback can still read an old value, why two objects share behavior, why a promise callback runs before a timer, or why a page's memory usage grows after navigating away.

This chapter develops those connections. It starts with closures and object prototypes, then examines this, the event loop, iterators, generators, and structured errors. It finishes with a practical workflow for locating memory leaks in Chrome DevTools.

Overview & Learning Objectives

By the end of this chapter, you will be able to:

  1. Explain how closures retain lexical bindings and when that retention matters.
  2. Recognize common memory-retention and leak patterns in browser code.
  3. Describe the prototype chain and how ES classes use prototype-based behavior.
  4. Predict this for method calls, detached functions, and explicit bindings.
  5. Trace synchronous work, microtasks, and tasks through the event loop.
  6. Implement and consume iterators and generators.
  7. Create custom error types and handle failures with useful context.
  8. Use Chrome DevTools memory tools to investigate retained objects.

Closures and Lexical Scoping

A function's lexical environment is determined by where it is written, not where it is called. When a function refers to a variable declared outside its body, it closes over that binding. The function can keep using the binding after the outer function has returned.

js
function createCounter(start = 0) {
  let count = start;

  return {
    increment() {
      count += 1;
      return count;
    },
    current() {
      return count;
    },
  };
}

const counter = createCounter(4);
counter.increment(); // 5
counter.current(); // 5

The two methods share one count binding. Each call to createCounter creates a separate lexical environment, so another counter has independent state. The variable is private in the sense that callers cannot access it directly; they can only use the returned methods.

Closures capture bindings, not frozen snapshots

A closure refers to the variable binding. If that binding changes, later calls observe its current value:

js
function makeReader() {
  let message = "draft";
  const read = () => message;
  message = "published";
  return read;
}

makeReader()(); // "published"

This is useful for encapsulated state, callbacks, and factories. It can also produce stale-state bugs when a callback is created with assumptions about when it will run. In UI code, understand the framework's render and effect model rather than assuming every callback sees the latest props or state. When an event listener needs current values, update its source deliberately or register and clean up the listener as part of the relevant lifecycle.

Closures in loops

let creates a fresh binding for each iteration of a loop, while a var binding is shared across the function:

js
const callbacks = [];

for (let index = 0; index < 3; index += 1) {
  callbacks.push(() => index);
}

callbacks.map((callback) => callback()); // [0, 1, 2]

Older code using var can cause every callback to observe the final loop value. A common repair is to use let, or to pass the value to a helper that creates a separate scope.

Retention and closure design

A closure does not necessarily retain every local variable from its outer function. Modern engines can optimize what remains reachable. Still, a long-lived callback may retain objects it references, including data that is no longer needed:

js
function installHandler(element, largeDataset) {
  const handler = () => {
    element.textContent = largeDataset.title;
  };

  window.addEventListener("resize", handler);
  return () => window.removeEventListener("resize", handler);
}

The returned cleanup function is important: until the listener is removed, the browser can keep the handler and its reachable values alive. Keep closures focused, avoid capturing large structures when only a small field is needed, and end callback lifetimes explicitly.

Memory Management and Leak Patterns

JavaScript uses automatic memory management. A value remains available while it is reachable from roots such as active stack frames, global bindings, and browser-managed references. A garbage collector can reclaim objects that are no longer reachable; developers do not manually free ordinary objects.

Automatic collection does not prevent leaks. A leak occurs when an application retains values that it no longer needs. The browser may correctly keep them because a global collection, timer, event listener, cache, or detached DOM reference still points to them.

Common causes of retention

Unremoved event listeners. A listener can outlive the component or view that registered it. Use the same function reference to remove it, or use an AbortController signal:

js
const controller = new AbortController();

window.addEventListener("resize", handleResize, {
  signal: controller.signal,
});

// When the owner is disposed:
controller.abort();

Timers and intervals. Repeating timers keep callbacks active until cleared:

js
const intervalId = setInterval(updateClock, 1000);

function stopClock() {
  clearInterval(intervalId);
}

Unbounded caches. A Map or object used as a cache holds strong references to its keys and values. Add an eviction policy, a size limit, or a lifecycle. WeakMap can be appropriate when metadata should not keep an object alive, but it cannot be enumerated and is not a general replacement for every map.

Detached DOM trees. Removing a node from the document does not make it collectible if JavaScript still holds the node or one of its descendants. Release stale references and event handlers when the interface is removed.

Subscriptions and observers. Unsubscribe from event emitters, message channels, observers, and framework stores when the consumer is finished. Observers such as ResizeObserver should be disconnected when their observed elements are no longer relevant.

Closures over large values. A callback that only needs an identifier should not need to retain an entire response object or DOM subtree. Pass the minimal data needed.

A leak is different from a temporary peak

Rendering, navigation, image decoding, and data fetching can temporarily increase memory use. The important question is whether memory returns to a stable baseline after the work and its UI are released. Repeatedly performing the same action and seeing retained objects grow is stronger evidence of a leak than a single high reading.

Do not try to force garbage collection as an application fix. DevTools can expose a manual collection button during diagnosis, but production behavior must be correct without user-triggered collection.

Prototypal Inheritance and ES Classes

Every ordinary JavaScript object has an internal prototype link, which may point to another object or to null. Property lookup checks the object itself first, then follows this prototype chain until it finds a property or reaches the end. This delegation model is prototypal inheritance.

js
const animal = {
  speak() {
    return `${this.name} makes a sound`;
  },
};

const dog = Object.create(animal);
dog.name = "Milo";

dog.speak(); // "Milo makes a sound"

dog has no own speak property, so lookup finds animal.speak. The method call still uses dog as its receiver, making this.name resolve to "Milo".

Constructor functions and prototypes

Before class syntax, constructor functions were commonly used with new:

js
function User(name) {
  this.name = name;
}

User.prototype.describe = function () {
  return `User: ${this.name}`;
};

const user = new User("Nia");
user.describe();

The new operator creates an object, links it to User.prototype, calls User with that object as this, and returns the object unless the constructor explicitly returns another object. Methods assigned to the prototype are shared by instances rather than recreated as own properties on each instance.

Classes are syntax over prototypes

ES class syntax offers a more familiar way to define constructors and prototype methods:

js
class User {
  constructor(name) {
    this.name = name;
  }

  describe() {
    return `User: ${this.name}`;
  }
}

Instances still delegate to User.prototype; describe is not copied onto every instance. Classes also support extends, super, static members, and private fields. Class bodies execute in strict mode, and classes must be called with new.

js
class Admin extends User {
  constructor(name, permissions = []) {
    super(name);
    this.permissions = permissions;
  }

  can(permission) {
    return this.permissions.includes(permission);
  }
}

super(name) invokes the parent constructor and initializes this for the subclass. Prototype inheritance is powerful, but inheritance chains can make behavior harder to trace. Prefer composition when an object can be built from smaller collaborators without an “is-a” relationship.

Own properties and inherited properties

Object.hasOwn(object, key) checks whether the property belongs directly to the object. The in operator also checks the prototype chain:

js
"describe" in user; // true: inherited method
Object.hasOwn(user, "describe"); // false
Object.hasOwn(user, "name"); // true

Use the distinction when validating data or iterating properties. Never assume an untrusted object contains only own properties or has a particular prototype. For dictionary data with arbitrary keys, consider Map or an object created with a null prototype, and validate keys at boundaries.

this Binding

For regular functions, this is determined by how the function is called, not where it was defined. This is separate from lexical scope.

Method-call binding

When called as object.method(), a regular function receives object as this:

js
const account = {
  balance: 20,
  getBalance() {
    return this.balance;
  },
};

account.getBalance(); // 20

If the function is detached, the receiver is lost:

js
const readBalance = account.getBalance;
readBalance(); // undefined in strict mode, unless called with another receiver

ES modules are strict mode. In non-strict scripts, an unbound regular function may default this to the global object; do not depend on that legacy behavior.

Explicit binding with call and apply

call invokes a function immediately with a chosen this, followed by individual arguments. apply also invokes immediately, but receives arguments as an array-like value:

js
function describe(prefix, suffix) {
  return `${prefix}${this.name}${suffix}`;
}

const person = { name: "Nia" };

describe.call(person, "Hello, ", "!"); // "Hello, Nia!"
describe.apply(person, ["Welcome, ", "."]); // "Welcome, Nia."

Use these methods when an API or utility needs to call a function with a deliberate receiver. In modern code, spread syntax often makes call more convenient than apply when arguments are already in an array: fn.call(receiver, ...args).

Persistent binding with bind

bind returns a new function with a fixed this value and optionally preset arguments:

js
const boundDescribe = describe.bind(person, "Hello, ");
boundDescribe("!"); // "Hello, Nia!"

Binding is useful when passing a method as a callback. Each call to bind creates a distinct function, so save the result if it must later be removed as an event listener.

Arrow functions do not have their own this; they capture it lexically from the surrounding scope. Use arrows for callbacks that should share the enclosing receiver. Avoid using an arrow as an object method when the method expects this to be the object.

The Event Loop: Stack, Tasks, and Microtasks

JavaScript in a browser commonly runs application code on a main thread with a call stack. When a function is called, its frame is pushed; when it returns, the frame is popped. The current stack runs to completion before another queued callback begins.

The browser also provides Web APIs for work such as timers, network requests, user input, and rendering. When an asynchronous operation completes, its callback is made eligible to run through the event loop. Promises use the microtask queue; timer callbacks and many browser events are tasks (often informally called macrotasks).

Microtasks and tasks

After a synchronous script or task finishes, the runtime drains queued microtasks before moving on to the next task, subject to host scheduling details. Promise reactions and queueMicrotask callbacks are microtasks. A setTimeout callback is a task; its delay is a minimum scheduling delay, not a guarantee of execution at an exact time.

js
console.log("A");

setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("microtask"));

console.log("B");

The expected order in a browser is:

text
A
B
promise
microtask
timer

The two microtasks run in the order they were queued, after synchronous work. The timer is a later task. Rendering may occur between tasks; the browser's rendering schedule and other queued work mean code should not rely on a precise timer timestamp.

Long tasks and responsiveness

A long synchronous task blocks input handling and rendering on the main thread. Splitting CPU-heavy work into chunks, using a Web Worker for suitable computation, or yielding to the browser can preserve responsiveness. Scheduling thousands of chained microtasks can also starve tasks and rendering because the microtask queue is drained before the next task.

async functions do not make CPU-bound code run on another thread. They pause at await and resume through promise scheduling; expensive synchronous work before or after an await still blocks the thread. Profile before optimizing and choose a worker when computation can be isolated from the DOM.

Iterators and Generators

An iterable is an object that provides an iterator through Symbol.iterator. Iterators implement next(), which returns an object shaped like { value, done }. Language features such as for...of, array spread, and Array.from consume iterables.

A custom iterable

js
const range = {
  start: 2,
  end: 4,
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;

    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      },
    };
  },
};

[...range]; // [2, 3, 4]

Each call to [Symbol.iterator] returns a fresh iterator with its own progress. Capturing end locally avoids depending on a later mutation to range.end. The iterable protocol enables lazy consumption: a consumer can stop before every value is generated.

Generator functions

Generator functions, declared with function*, create iterators. yield returns a value and pauses the generator until the next request:

js
function* rangeValues(start, end) {
  for (let value = start; value <= end; value += 1) {
    yield value;
  }
}

for (const value of rangeValues(2, 4)) {
  console.log(value);
}

Generators can express lazy sequences without allocating an array containing every result. They are useful for pipelines, parsing, and controlled iteration. They are synchronous by default; asynchronous iteration uses async function*, for await...of, and the async iterable protocol.

A generator can also receive a value through next(value) after it has paused at yield, though this is less common in ordinary application code. Calling return() asks the generator to finish and can trigger finally cleanup. Consumers should close iterators appropriately when early termination has resource implications.

Structured Errors and Custom Error Classes

Errors communicate failure. Throw errors when a function cannot fulfill its contract, and catch them where the program can meaningfully recover, add context, or present a user-facing message. Avoid catching an error only to suppress it or return a success-shaped fallback.

Custom error types

Extend Error to give callers a reliable type to test:

js
class HttpError extends Error {
  constructor(message, status, options) {
    super(message, options);
    this.name = "HttpError";
    this.status = status;
  }
}

Modern JavaScript supports an error cause through the options argument. Preserve a lower-level cause when adding context:

js
async function loadProfile(id) {
  try {
    const response = await fetch(`/api/profiles/${encodeURIComponent(id)}`);
    if (!response.ok) {
      throw new HttpError("Profile request failed", response.status);
    }
    return await response.json();
  } catch (error) {
    throw new Error(`Unable to load profile ${id}`, { cause: error });
  }
}

In a real application, avoid wrapping an error if doing so destroys useful type information without adding value. The example wraps to add operation context while preserving the cause. Do not include secrets, access tokens, or sensitive user data in error messages that may be logged.

Handling failures deliberately

js
try {
  const profile = await loadProfile("user-42");
  renderProfile(profile);
} catch (error) {
  if (error instanceof HttpError && error.status === 404) {
    showNotFound();
  } else {
    reportError(error);
    showRetryMessage();
  }
}

Check errors at the appropriate boundary. If errors can cross realms or module boundaries where instanceof is unreliable, use a stable error code or validated shape. For multiple concurrent operations, Promise.allSettled can collect independent outcomes; Promise.all rejects as soon as one input rejects. Choose based on whether partial success is meaningful.

Chrome DevTools Memory Profiling

A repeatable scenario is more useful than a single memory reading. Use a development build that reproduces the behavior, then compare snapshots around the suspected lifecycle.

Heap snapshot workflow

  1. Open Chrome DevTools and select the Memory panel.
  2. Choose Heap snapshot and take a baseline snapshot after the page reaches a stable state.
  3. Perform the suspected action repeatedly—for example, open and close a panel, navigate to a route, or mount and unmount a component.
  4. Allow asynchronous work to settle. Use the DevTools garbage-collection control when available to reduce noise during diagnosis.
  5. Take another snapshot and compare it with the baseline.
  6. Inspect Retainers for objects that remain reachable. The retaining path explains which listener, collection, DOM node, or closure keeps the object alive.
  7. Fix the owner of the reference, repeat the exact scenario, and verify the retained objects no longer grow.

Use the Allocation instrumentation on timeline recording when you need to see allocations over a sequence of interactions. The Allocation sampling profile can help identify code allocating many objects with lower overhead. Panel labels can vary slightly between Chrome versions.

Read the evidence carefully

Look for repeated instances of the same component data, detached DOM trees, growing arrays, and listeners retaining large closures. A large shallow size is not the same as a large retained size: an object with a small direct footprint may keep a large object graph reachable. Follow the retainer path before deciding which reference to remove.

Profiles from development can include framework instrumentation, source maps, hot-reload modules, and debugging tools that change memory behavior. Confirm important findings in a production-like build. Avoid collecting or sharing heap snapshots containing personal data, tokens, or other sensitive values.

Common Anti-Patterns, Bugs & Debugging Guidance

  • Assuming a closure snapshots a value: closures capture bindings, whose values can change. Identify when the callback is created and when it runs.
  • Leaking listeners and timers: retain cleanup handles and invoke cleanup when the owner ends.
  • Using inheritance for every reuse case: prototype chains can obscure behavior; prefer composition when objects do not form a true specialization relationship.
  • Detaching a method: const fn = object.method; fn() does not retain the original receiver. Bind it or use a wrapper that calls object.method().
  • Confusing promise callbacks with timers: promise reactions run as microtasks; timers are tasks and are not exact clocks.
  • Expecting async to move work off-thread: CPU work remains synchronous unless delegated to a worker.
  • Building a full array for a huge sequence: use an iterator or generator when lazy production materially reduces memory.
  • Catching and ignoring errors: recover only when the program has a meaningful fallback; otherwise propagate or report with context.
  • Treating any heap growth as a leak: repeat a controlled lifecycle and inspect retainers after cleanup and collection.

For debugging, write the smallest example that reproduces the behavior, inspect the actual call receiver or retained path, and add a regression test for the intended contract. Avoid inferring runtime order from one complicated console log; simplify it into explicit synchronous, promise, and timer steps.

Accessibility, Performance & Industry Best Practices

  • Keep callback lifetimes aligned with the UI or service that owns them.
  • Clean up listeners, timers, observers, subscriptions, and external resources.
  • Use closures to encapsulate state, but avoid retaining large data unnecessarily.
  • Prefer composition to deep inheritance hierarchies.
  • Make this requirements explicit with method calls, wrappers, or bind.
  • Keep main-thread tasks short; profile before moving work or adding scheduling complexity.
  • Use iterators for lazy processing when the consumer may not need all values.
  • Preserve causal context in errors without exposing secrets or personal data.
  • Test failure paths and user feedback as well as the successful path.
  • Measure memory with repeatable snapshots and confirm suspected retainers before changing code.
  • Keep accessibility in mind when scheduling UI work: delayed rendering, focus changes, and status messages should remain understandable and operable for assistive technology users.

Hands-on Exercises

Exercise 1 — Fix a listener leak and verify cleanup

The following function adds a resize listener every time a dashboard is opened. It never removes the listener, and the callback closes over the entire dashboardData object:

js
function openDashboard(dashboardData) {
  window.addEventListener("resize", () => {
    renderSummary(dashboardData.summary);
  });
}

Refactor it to return a cleanup function, use a stable callback reference, and avoid retaining more data than the callback needs. Explain how you would verify the behavior with repeated open/close cycles and a heap snapshot.

Exercise 2 — Trace scheduling and implement a generator

Given the code below, write its output order and explain why:

js
console.log("start");

setTimeout(() => console.log("timer"), 0);

Promise.resolve().then(() => {
  console.log("promise");
  queueMicrotask(() => console.log("nested microtask"));
});

console.log("end");

Then implement a generator takeEverySecond(values) that yields entries at indexes 0, 2, 4, and so on without creating a second array. Demonstrate consuming it with for...of.

Solutions

Solution 1

js
function openDashboard(dashboardData) {
  const summary = dashboardData.summary;

  function handleResize() {
    renderSummary(summary);
  }

  window.addEventListener("resize", handleResize);

  return function closeDashboard() {
    window.removeEventListener("resize", handleResize);
  };
}

const closeDashboard = openDashboard(data);
// When the dashboard closes:
closeDashboard();

The callback is a named function whose reference is retained for removal. Capturing summary rather than the full dashboard object narrows the data reachable through the closure; if summary itself is large, capture only the specific fields needed. The cleanup function removes the listener and ends the window's reference to the callback.

In DevTools, take a baseline heap snapshot, repeatedly open and close the dashboard, then take a comparison snapshot after work settles and garbage collection is available. Inspect retaining paths for listener closures and dashboard objects. After cleanup, repeated cycles should not retain a growing number of old callbacks or summaries. Also test that resizing while open still updates the summary.

An AbortController is another valid lifecycle pattern: register the listener with { signal } and call controller.abort() during cleanup. Retain the controller in the same owner that manages the dashboard lifecycle.

Solution 2

The output is:

text
start
end
promise
nested microtask
timer

The first two logs run synchronously. The resolved promise schedules a microtask. Once the current script finishes, the promise callback runs and queues another microtask. The runtime drains that nested microtask before processing the timer task.

js
function* takeEverySecond(values) {
  for (let index = 0; index < values.length; index += 2) {
    yield values[index];
  }
}

for (const value of takeEverySecond(["a", "b", "c", "d", "e"])) {
  console.log(value);
}
// "a", "c", "e"

The generator yields each selected value on demand and does not allocate a second array. It assumes an array-like input with a numeric length; a production utility intended for arbitrary iterables would need a different strategy that counts values as it iterates.