The Dashboard That Ran Out of Memory Because of How Class Actually Works

typescript dev.to

ES6 class syntax made JavaScript look like Java or C++. It didn't change the runtime underneath. Every property lookup still walks a prototype chain, and every method you define in the wrong place gets duplicated across every instance you create.

The dashboard rendered a few thousand rows without complaint. It rendered a few hundred thousand rows the kind of scale a large enterprise customer's dataset actually produced by consuming memory fast enough that the tab crashed before the render finished.

The component causing it looked entirely ordinary. A class for each row entity, a constructor, and a few methods for formatting and click handling. Nothing about it looked wrong to anyone who had learned JavaScript through the lens of class as "JavaScript's version of a real OOP class." The bug was exactly that framing. Every method was being assigned inside the constructor: handleClick = function() { ... }, which meant every single instantiated row created its own private copy of every method. Two hundred thousand rows meant two hundred thousand copies of functions that were byte-for-byte identical and should have been shared.

The fix was moving those method definitions from the constructor body to the class body, a change of a few lines that reduced memory consumption by an order of magnitude. The bug was not a framework problem, not a rendering library problem, and not something a linter would have caught by default. It was a fundamental misunderstanding of what class actually compiles to at runtime.

JavaScript is not, and has never been, a classical object-oriented language underneath its class keyword. It is a prototype-based, delegation language wearing OOP syntax. Understanding that distinction is not academic; it directly determines how much memory your objects consume and how fast property lookups resolve in your hottest execution paths.

The mechanism: what class actually does

class in JavaScript is syntactic sugar over the same prototype-based object model that existed before ES6. It does not introduce a new runtime concept. Every class declaration still produces a constructor function and a prototype object, and every instance still resolves properties by walking a prototype chain.

class Row {
  constructor(data) {
    this.data = data;
  }

  render() {
    return `<tr>${this.data}</tr>`;
  }
}

// Under the hood, this is equivalent to:
function Row(data) {
  this.data = data;
}
Row.prototype.render = function() {
  return `<tr>${this.data}</tr>`;
};
Enter fullscreen mode Exit fullscreen mode

render is defined once, on Row.prototype. Every instance of Row shares that single function object. When you call instance.render(), the engine does not find render directly on instance; it looks at instance, does not find it, follows instance.__proto__ (which points to Row.prototype), finds render there, and executes it.

This chain-walking is the mechanism behind every property access and every method call in JavaScript:

Instance object (Row #4821)
  └─ __proto__ → Row.prototype (shared: render, formatDate, etc.)
        └─ __proto__ → Object.prototype (shared: toString, hasOwnProperty, etc.)
              └─ __proto__ → null (chain terminates)
Enter fullscreen mode Exit fullscreen mode

The engine walks this chain step by step until it finds a matching property or reaches null. This is not a metaphor for how JavaScript objects work; it is the literal runtime lookup algorithm, and it has direct performance and memory consequences depending on where you define things.

The real-world cost: two mistakes that come from the same misunderstanding

Assigning methods inside the constructor duplicates them per instance.
This is the mistake that caused the dashboard crash. When a method is assigned as an instance property inside the constructor rather than defined in the class body, which places it on the prototype, every single instance gets its own independent copy of that function.

// Wrong — every instance gets its own copy of handleClick
class RowWrong {
  constructor(data) {
    this.data = data;
    this.handleClick = function() {
      trackEvent('row_click', this.data.id);
    };
  }
}

// Correct — handleClick is defined once, on the prototype, shared by all instances
class RowCorrect {
  constructor(data) {
    this.data = data;
  }

  handleClick() {
    trackEvent('row_click', this.data.id);
  }
}
Enter fullscreen mode Exit fullscreen mode

For a handful of instances, the difference is invisible. For thousands of entities in a data-heavy dashboard, the wrong version means thousands of independent function objects sitting in heap memory, each identical to every other, each consuming space that a shared prototype method would not.

The common reason engineers reach for the constructor-assignment pattern is binding this. this.handleClick = () => { ... } as an arrow function does correctly bind this to the instance, but it pays for that convenience with per-instance duplication. The better trade-off in most cases is defining the method on the prototype and binding at the call site, or using a class field arrow function only where the number of instances is genuinely small.

// If you need bound `this` and instance count is small, class fields are fine
class Button {
  handleClick = () => {
    this.trackClick();
  };
}

// If instance count is large, bind explicitly where needed instead
class Row {
  handleClick() {
    trackEvent('row_click', this.data.id);
  }
}

// Bind only where the reference is actually needed as a standalone callback
const bound = row.handleClick.bind(row);
Enter fullscreen mode Exit fullscreen mode

The rule of thumb: the more instances you expect to create, the more the memory cost of per-instance method duplication compounds, and the more it matters to keep methods on the prototype.

Deep inheritance chains slow down every property lookup

The second mistake is architectural rather than a single-line bug: building deep inheritance hierarchies where class A extends B extends C extends D.

class Entity { /* ... */ }
class Renderable extends Entity { /* ... */ }
class Interactive extends Renderable { /* ... */ }
class ListItem extends Interactive { /* ... */ }
class UserRow extends ListItem { /* ... */ }

const row = new UserRow(data);
row.someMethodDefinedOnEntity(); // walks 5 levels of prototype chain
Enter fullscreen mode Exit fullscreen mode

Every property access on row that resolves to a method defined on Entity requires the engine to walk through UserRow.prototype, ListItem.prototype, Interactive.prototype, Renderable.prototype, before finally finding the method on Entity.prototype. V8's inline caching optimizes repeated lookups of the same shape considerably, but deep chains still introduce more work per miss and more shapes for the engine to track, and the cost compounds specifically in tight loops rendering thousands of rows and processing high-frequency events where the same lookup happens repeatedly under time pressure.

The architectural fix is composition over deep inheritance: favor flat hierarchies, and where shared behavior is genuinely needed across otherwise unrelated types, use composition (mixins, functions that attach behavior, or plain object composition) rather than extending a long chain of increasingly specific subclasses.

// Flat, composed — no deep chain to walk
function withClickable(Base) {
  return class extends Base {
    handleClick() {
      trackEvent('click', this.id);
    }
  };
}

class Entity { /* base fields */ }
class UserRow extends withClickable(Entity) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

This keeps the prototype chain for UserRow at two levels instead of five, regardless of how many behaviors are composed in, because each mixin function returns a class that extends directly from the base rather than stacking indefinitely.

The fix: three rules for prototype-aware class design

Define all shared behavior in the class body, never in the constructor.
Any method that does not need to differ between instances belongs in the class body, where it compiles to a single shared entry on the prototype. Reserve constructor assignments for actual instance-specific data.

class ListItem {
  constructor(data) {
    this.data = data;         // instance-specific — correct in constructor
    this.selected = false;    // instance-specific — correct in constructor
  }

  // Shared across every instance — correct in class body
  render() { /* ... */ }
  toggleSelect() { this.selected = !this.selected; }
  formatDate() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Add this as a specific code review question for any class handling a large number of instances: "Does this method need to differ per instance, or is it identical logic every time?" If it is identical, it belongs on the prototype.

Keep prototype chains to two or three levels

Treat extends chains longer than two or three levels as an architectural smell worth questioning in review. Where behaviour genuinely needs to be shared across otherwise unrelated types, reach for composition, mixins, or plain functions that attach behaviour to an object, rather than continuing to extend.

Never modify built-in prototypes

Mutating Object.prototype, Array.prototype, or any other built-in prototype affects every object of that type across your entire application, including objects created by third-party dependencies that have no idea your mutation exists.

// Never do this in production code
Object.prototype.customHelper = function() { /* ... */ };

// This single line means EVERY object literal, EVERY class instance,
// and EVERY object created by every dependency in your bundle
// now has `customHelper` — whether they want it or not
Enter fullscreen mode Exit fullscreen mode

Beyond the obvious collision risk, mutating built-in prototypes disables engine optimizations that depend on stable, predictable object shapes for built-in types. V8's inline caching assumes Object.prototype behaves consistently; violating that assumption can degrade performance in ways that are difficult to trace back to the actual cause, because the mutation and the performance regression can be far apart in the codebase.

Key takeaway

class is syntax. Prototype delegation is the runtime truth underneath it. Every method call, every property access, and every instanceof check ultimately resolves through the same chain-walking mechanism that existed in JavaScript before class was introduced. The keyword changed how the code reads, not how the engine executes it.

The dashboard that ran out of memory did not fail because of a framework limitation or a rendering bug. It failed because class syntax made it easy to write code that looked correct by the conventions of classical OOP while being expensive by the actual rules of JavaScript's object model. Seniority here means holding both models at once: writing class syntax that reads cleanly for the next engineer while understanding precisely what that syntax compiles to for the engine that has to execute it at scale.

What to audit this week

# Find methods assigned inside constructors instead of the class body —
# the exact pattern that duplicates functions per instance
grep -rn "this\.\w* = function\|this\.\w* = (.*) =>" src/ --include="*.ts" --include="*.tsx"

# Find deep inheritance chains — anything extending a class that itself extends another
grep -rn "class .* extends" src/ --include="*.ts" --include="*.tsx"

# Find any modification of built-in prototypes — should return zero results
grep -rn "Object\.prototype\.\|Array\.prototype\.\|String\.prototype\." src/
Enter fullscreen mode Exit fullscreen mode

Any result from the first search on a class instantiated more than a handful of times is a memory duplication candidate worth moving to the prototype. Any result from the third search should not exist in your codebase at all; treat it as a blocking issue regardless of how small the addition seems.

Source: dev.to

arrow_back Back to Tutorials