TanStack Table V9 Prototype Sharing — 90% Memory Savings in JS
Technical Deep-Dive · Updated 2026

How an Underrated Refactor Saved
90% Memory in TanStack Table V9

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.

Oleg Maximov June 25, 2026 12 min read

Introduction

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.

The Problem: Object Literals Create Per-Instance Overhead

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.

V8: Object literal — every instance carries ALL methods Before
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 Solution: Shared Prototypes with Object.create

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.

V9: Shared prototype — methods defined ONCE After
// 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.

Benchmark Results

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.

272.58 MB → 27.28 MB Paginated rows (100,000 x 8 columns) — 90% memory savings
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.

Why Not Use JavaScript Classes?

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:

Classes don't solve the problem — bound methods recreate per-instance overhead
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.

How It Works: JavaScript Prototype Chain Mechanics

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.

Why Not Do This in Table V8?

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.

Applying This Pattern to Your Own Projects

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:

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.

Trade-offs and Caveats

No pattern is universally better. Here's when prototype sharing isn't the right choice:

FAQ

What is prototype sharing in JavaScript?
Prototype sharing means storing methods on a shared prototype object instead of defining them on every object instance. Using 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.
How much memory did TanStack Table V9 save with prototype sharing?
Up to 90% for large tables. In the paginated rows benchmark with 100,000 rows × 8 columns (800,000 cells), memory dropped from 272.58 MB in V8 to 27.28 MB in V9 — a saving of 245.30 MB. At 1 million rows, V8 used 2.7 GB while V9 used only 257 MB. The improvement is consistent across paginated, virtualized, and feature-rich kitchen-sink scenarios.
Why not use JavaScript classes instead of Object.create?
Class methods work fine for direct calls, but TanStack Table passes methods by reference to memoized functions and plugin systems. Bound methods (via .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.
Can I use prototype sharing in my own JavaScript projects?
Yes. Any library or application that creates many similar objects (rows, cells, nodes, entities) can benefit. The technique is especially effective in data grids, virtualized lists, entity-component systems in games, and any library with a plugin/feature architecture that adds methods to instances. The pattern is Object.create(prototype) + Object.assign(data) in a factory function.
What are the downsides of prototype sharing?
The main downside is a single breaking change: any code using 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.
Why wasn't this optimization already in TanStack Table V8?
The PR introducing prototype sharing was created years ago by Michael Leibman but never merged due to the breaking change it introduced — enumerable prototype methods would break consumers using 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.
What's the maximum number of rows TanStack Table V9 can handle?
According to published benchmarks, Table V9 can handle approximately 10-16 million rows before reaching the browser's typical 4 GB memory limit — a roughly 10x improvement over V8's 1-1.5 million row ceiling. For most applications, you'll hit other browser rendering limits before memory, but the headroom is dramatically larger for edge cases like data analysis tools or admin dashboards with massive datasets.

Final Thoughts

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.

Contact

Need performance help?

Working on a JavaScript application with memory or performance issues? I can help optimize your data-heavy components. Free initial consultation.