A surprisingly simple pattern — storing methods on shared prototypes instead of object literals — cut memory usage from 272 MB to 27 MB for large tables. Here's how it works and why you can use it in your own projects.
TanStack Table V9 has been in development for years, bringing a ground-up rewrite with a new state management system, a slimmer plugin/feature architecture, and a "pay for what you use" runtime model. But one of the biggest performance wins came from an unexpected place: a refactor so subtle that most developers would scroll past it in a PR review.
The change: instead of defining all methods directly on every Row, Column, Cell, and
Header object instance, TanStack Table V9 stores them on shared prototype
objects and creates instances via Object.create(proto). The result
is up to 90% less memory usage for large tables — from 272 MB down to
27 MB for 100,000 rows with pagination.
This article is a deep-dive into how the refactor works, the before-and-after code patterns, the benchmarks, and why this technique is applicable to any JavaScript library that creates many similar objects.
In TanStack Table V8, every Row, Column, Cell, and Header was a plain object literal.
Each one carried not just its data properties (like id, index,
original) but also every method — getValue,
getUniqueValues, renderValue, getLeafRows,
getParentRow, getAllCells, and a dozen more from feature
plugins.
const row = {
// data
id,
index: rowIndex,
original,
depth,
parentId,
_valuesCache: {},
_uniqueValuesCache: {},
// methods — duplicated on EVERY row instance
getValue: (columnId) => {
// ...implementation...
},
getUniqueValues: (columnId) => {
// ...implementation...
},
renderValue: (columnId) =>
row.getValue(columnId) ?? table.options.renderFallbackValue,
getLeafRows: () => flattenBy(row.subRows, (d) => d.subRows),
getParentRow: () =>
row.parentId ? table.getRow(row.parentId, true) : undefined,
getAllCells: memo(/* ... */),
_getAllCellsByColumnId: memo(/* ... */),
// maybe a dozen other methods from features ...
}
With 100,000 rows and 10+ methods each, that's over 1 million function objects created for methods alone — and every single one is an independent closure with its own scope chain. The V8 engine doesn't deduplicate these because each object literal creates fresh function instances.
The fix is elegant and conceptually simple: create one prototype object per type (Row,
Column, Cell, Header) with all the methods, then use
Object.create(prototype) to spawn instances that inherit methods from
the shared prototype. Data properties are set via Object.assign on each
instance.
// Shared prototype — created once
const rowPrototype = {
getValue(columnId) {
// ...this works because 'this' refers to the instance...
},
getUniqueValues(columnId) {
// ...implementation...
},
renderValue(columnId) {
return this.getValue(columnId) ??
this.table.options.renderFallbackValue;
},
getLeafRows() {
return flattenBy(this.subRows, (d) => d.subRows);
},
getParentRow() {
return this.parentId
? this.table.getRow(this.parentId, true)
: undefined;
},
getAllCells: memo(/* ... */),
_getAllCellsByColumnId: memo(/* ... */),
};
// Instance creation — one line, minimal memory
function createRow(data) {
const row = Object.create(rowPrototype);
Object.assign(row, {
id: data.id,
index: data.rowIndex,
original: data.original,
depth: data.depth,
parentId: data.parentId,
_valuesCache: {},
_uniqueValuesCache: {},
});
return row;
}
Every row now stores only its data properties (~6 fields). The 10+ methods live on the
shared rowPrototype — one copy of each, regardless of how many rows exist.
The memory savings compound across Row, Column, Cell, and Header types, each of which
had its own set of methods.
The numbers speak for themselves. TanStack published benchmarks measuring retained JS heap size across four scenarios: paginated rows, virtualized rows, virtualized columns, and a kitchen-sink example combining all features.
| Scenario | Cells (rows × cols) | V8 Memory | V9 Memory | Saved | % Improvement |
|---|---|---|---|---|---|
| Paginated rows | 800K (100K × 8) | 272.58 MB | 27.28 MB | 245.30 MB | 90.0% |
| Paginated rows | 8M (1M × 8) | 2,710.06 MB | 257.19 MB | 2,452.87 MB | 90.5% |
| Virtualized rows | 800K (100K × 8) | 273.42 MB | 28.12 MB | 245.30 MB | 89.7% |
| Virtualized rows | 8M (1M × 8) | 2,714.32 MB | 261.46 MB | 2,452.86 MB | 90.4% |
| Virtualized columns | 1M (100 × 10K) | 230.47 MB | 80.24 MB | 150.23 MB | 65.2% |
| Kitchen sink | 800K (100K × 8) | 272.83 MB | 36.91 MB | 235.92 MB | 86.5% |
The pattern is consistent: the more cells TanStack Table needs to process, the more dramatic the savings become. For small tables (80 cells), the difference is negligible (1-2%). But at scale — which is precisely where memory matters — the improvement is transformative.
A natural question: if methods on a prototype work so well, why not just use ES6 classes? Class methods do live on the prototype, after all.
The issue is that TanStack Table passes methods by reference to memoized functions,
event handlers, and feature plugins. When you do this, the this context
gets lost. The standard fix — using .bind(this), arrow functions, or
getter properties that return bound methods — creates a new function instance
per row, defeating the purpose:
class Row {
constructor(data) {
this.id = data.id;
this.index = data.rowIndex;
// ...
// If we bind methods here, they're per-instance again:
this.getValue = this.getValue.bind(this); // new function every row!
this.renderValue = this.renderValue.bind(this); // same!
}
getValue(columnId) { /* ... */ }
renderValue(columnId) { /* ... */ }
}
With Object.create, you get the best of both worlds: prototype-level
method sharing, no binding overhead, and this resolved dynamically at
call time via the prototype chain. The methods on the prototype use this
the same way class methods do — but they never need binding because the calling code
receives the instance directly.
For developers unfamiliar with the mechanism, here's a refresher on what
Object.create actually does:
const proto = {
greet() { return `Hello, I am ${this.name}`; }
};
const alice = Object.create(proto);
alice.name = 'Alice';
const bob = Object.create(proto);
bob.name = 'Bob';
console.log(alice.greet()); // "Hello, I am Alice"
console.log(bob.greet()); // "Hello, I am Bob"
// Both share ONE greet() function:
console.log(alice.greet === bob.greet); // true
// But each has its own 'name':
console.log(alice.hasOwnProperty('name')); // true
console.log(alice.hasOwnProperty('greet')); // false — inherited
The key insight: alice.greet === bob.greet is true because
greet lives on proto, not on each instance. The method is
stored once in memory, yet both objects can call it with their own this
context.
The PR introducing prototype sharing was created years ago by Michael Leibman. It was never merged because of one critical issue: enumerable prototype methods.
In JavaScript, methods defined on a prototype are enumerable: true by
default when assigned directly as properties. This means they show up in
Object.keys(), for...in loops, and object spread operations:
const row = Object.create(rowPrototype);
Object.assign(row, { id: '1', index: 0 });
// V8 code might do this and get unexpected methods:
console.log(Object.keys(row));
// ['id', 'index'] — OK, only own properties
// But spread or for...in enumerates prototype methods:
console.log({ ...row });
// { id: '1', index: 0, getValue: fn, renderValue: fn, ... } — BAD!
Fixing every consumer to use hasOwnProperty or Object.keys
(which only enumerates own properties) was a breaking change. Table V8 couldn't absorb
it. Table V9, with its major version bump and other breaking changes, was the right
opportunity.
The fix for consumers is simple: use Object.keys(instance) instead of
{ ...instance } or for...in when you only want data
properties. Or define prototype methods as non-enumerable with
Object.defineProperty, though this adds boilerplate.
The beauty of this technique is its generality. Any library or application that creates many objects with the same set of methods can benefit. Here's a checklist for when it makes sense:
Object.keys.Here's a generic implementation pattern you can adapt:
// 1. Define the shared prototype
const entityPrototype = {
update(deltaTime) {
this.x += this.vx * deltaTime;
this.y += this.vy * deltaTime;
},
getPosition() {
return { x: this.x, y: this.y };
},
isOutOfBounds(width, height) {
return this.x < 0 || this.x > width ||
this.y < 0 || this.y > height;
},
};
// 2. Factory function using Object.create + Object.assign
export function createEntity({ x, y, vx, vy, type }) {
const entity = Object.create(entityPrototype);
Object.assign(entity, { x, y, vx, vy, type });
return entity;
}
In this particle-system example with 10,000 entities, each entity stores only
5 number properties. The 3 methods are shared. Without prototype sharing, every
particle would carry its own copy of update, getPosition,
and isOutOfBounds — 30,000 function objects versus 3.
No pattern is universally better. Here's when prototype sharing isn't the right choice:
Object.create(prototype), you create objects that inherit methods from the shared prototype, so each instance stores only its unique data properties. This dramatically reduces memory usage when creating thousands of similar objects..bind(), arrow functions, or getters) create per-instance closures, losing the memory advantage. Object.create with shared prototypes avoids this entirely — this is resolved dynamically via the prototype chain at call time.Object.create(prototype) + Object.assign(data) in a factory function.Object.keys(), for...in, or spread operators on the objects will also enumerate inherited prototype methods unless the methods are defined as non-enumerable with Object.defineProperty. The fix is to use Object.keys (own properties only) or add hasOwnProperty checks. Additionally, V8's JIT can devirtualize class-based property lookups more easily, but in practice the memory savings far outweigh this minor cost.for...in or object spread. Table V8 couldn't absorb this breaking change. Table V9's major version bump, with its other breaking architectural improvements like the new state management and plugin system, was the right opportunity to land this change.The TanStack Table V9 memory story is a reminder that the most impactful optimizations aren't always the most complex ones. A deep understanding of JavaScript's prototype system — a language feature that many developers use daily without thinking about — unlocked a 90% memory reduction that benefits every user of the library.
This pattern is transferable. Whether you're building a data grid, a virtualized list,
a game engine, or any system that creates many similar objects, Object.create
with shared prototypes is a low-effort, high-impact optimization that costs almost nothing
to implement during a major version cycle.
If you're building performance-critical JavaScript applications and want expert guidance, reach out to me. I specialize in React and Node.js performance optimization, with 20+ years of experience building production applications that scale.
Working on a JavaScript application with memory or performance issues? I can help optimize your data-heavy components. Free initial consultation.