At how many entities does an ECS start paying off?

There is no crossover. A struct-of-arrays layout beat an array of plain objects at every entity count measured, including 1,000 — but at 1,000 it saved 0.005 ms per frame. The saving first reaches a whole millisecond at 184,000 entities. And the cache-locality argument everyone repeats reverses on i

At how many entities does an ECS start paying off?

At about 184,000 entities — and not because there is a crossover. A struct-of-arrays layout was faster than an array of plain objects at every count measured, including 1,000. At 1,000 entities it saved 0.005 ms per frame, which is 0.03% of a 16.67 ms budget and not a reason to rewrite anything. 184,000 is where the saving first reaches a full millisecond (1.601 ms vs 0.466 ms). Below that number the layout argument is real and irrelevant at the same time.

Hardware: Apple M3, 16 GB, macOS 26.4.1 (build 25E253), 64 KB L1d, 4 MB L2, 16 KB pages. Node v23.5.0, built-ins only — no packages, no engine, no GPU. Every figure is a p50 with inner repeats so that each sample is milliseconds rather than microseconds, after three warm-up passes.

This is JavaScript, not C# and not C++. V8's object model is nothing like Unity's — a C# class instance has no hidden class to transition and no dictionary-mode failure state, and Burst-compiled DOTS jobs vectorise in a way nothing here does. The relative shape of these results transfers: cost follows bytes touched, and the reversal at high field counts is a property of hardware prefetchers, not of V8. The absolute crossover point does not.

The short answer

  • There is no entity count below which plain objects win. Struct-of-arrays was 4.28x to 7.33x faster at 1,000 through 1,000,000 entities. The question is not whether it wins, but whether the win is worth anything: at 1,000 entities it is 0.005 ms per frame.
  • The saving reaches 1 ms per frame at 184,000 entities and the object layout blows a 60 fps budget entirely at 1,792,000 (17.2 ms, against 3.4 ms for struct-of-arrays).
  • Cache locality is the right explanation, and it cuts both ways. A system reading 2 of 20 fields ran 21x faster in struct-of-arrays. A system reading all 20 ran 4.3x slower than the same work over plain objects, and 6.3x slower than an interleaved flat array. Past about 8 concurrent arrays the per-element cost rises from 0.30 ns to 2.58 ns.
  • The real killer in JavaScript is not layout, it is shape. The same object loop cost 6.15 ms with one hidden class, 35.36 ms with twelve (5.8x), and 140.94 ms after a single delete put the objects into dictionary mode (22.9x). That is a bigger effect than every layout decision here combined.
  • Archetype fragmentation barely costs anything. Splitting 500,000 matching entities across 8,192 archetype chunks of 61 entities each cost 26% over one contiguous chunk — 0.80 ns to 1.01 ns per entity.
Struct-of-arrays wins 18.5x reading one field and loses 4.3x reading all twenty

How the three layouts are built

Same simulation, same seeded data, three storages.

  1. Array of objects. new Array(n) of object literals created from one generated constructor, so all n share a single hidden class: {x, y, vx, vy, hp, energy, regen, armor}, eight doubles.
  2. Struct-of-arrays (SoA). One Float64Array(n) per field, eight arrays, hoisted into locals before the loop.
  3. Array-of-structs (AoS). One flat Float64Array(n * 8), interleaved, stride 8; entity i's fields live at d[i*8 .. i*8+7].

One frame runs two systems: move touches x, y, vx, vy; regen touches energy, regen and clamps. Two systems over disjoint fields, which is the pattern an ECS is supposed to reward.

A fourth column is worth having: the same objects with the iteration order shuffled, which is what an object array becomes after a few thousand spawns and despawns. Allocation order is the best case for objects and nobody's game stays in it.

Where does the crossover actually fall?

p50 ms per frame, both systems, eight fields:

n objects objects (shuffled) SoA AoS SoA speedup
1,000 0.006 0.006 0.001 0.002 5.49x
3,000 0.013 0.016 0.004 0.006 3.78x
10,000 0.055 0.069 0.013 0.024 4.15x
30,000 0.157 0.150 0.065 0.087 2.42x
100,000 0.791 3.113 0.379 0.485 2.09x
300,000 2.648 11.569 0.807 1.108 3.28x
1,000,000 10.067 57.885 2.572 4.237 3.91x

Throughput tells the story better than the ratio. Struct-of-arrays managed 948,982 entities/ms at 1,000 and 388,823 at 1,000,000 — it loses about 60% of its rate across three orders of magnitude. Plain objects went from 172,793 to 99,337 and were already down to 126,354 at 100,000. Both degrade; objects start lower and fall off sooner.

The shuffled column is the one to take personally. Below 30,000 entities, scrambling the iteration order costs nothing — the whole working set is in cache. At 100,000 it costs 3.9x, at 300,000 4.4x, and at 1,000,000 it costs 5.75x: 57.9 ms per frame against 10.1 ms. Pointer order matters more than the choice of layout. An object array that is merely in a bad order is 22x slower than struct-of-arrays, where a well-ordered one is 3.9x slower.

Memory follows the same line. One million entities cost 223.7 bytes each as objects (213 MB of V8 heap, 307 MB RSS) and 63.7 bytes each in either typed-array layout (61 MB external, 112 MB RSS) — 3.5x, and exactly the 8 fields x 8 bytes you asked for with nothing on top.

Is it really cache locality?

Yes, and the test that proves it is the same test that breaks the argument.

Read-only systems over 1,000,000 entities with 20 fields each, p50 ms:

system objects SoA AoS
reads 1 field 12.713 0.686 5.000
reads 2 fields (read-modify-write) 32.235 1.104 14.933
reads all 20 fields 12.264 52.525 8.313

Look at the object column: 12.71 ms to read one field, 12.26 ms to read all twenty. Reading one field out of an object costs the same as reading the whole object, because the cache line arrives either way. That is the cache-locality claim, confirmed, and it is why the ECS argument exists.

Now look at the SoA column: 0.686 ms for one field, 52.525 ms for twenty — a 77x range. Struct-of-arrays pays exactly for what it touches, which is the win when a system touches two fields and a catastrophe when it touches all of them. At 20 fields SoA was 4.3x slower than the plain objects it was supposed to replace, and 6.3x slower than the interleaved layout.

The mechanism is measurable. Reading S of 20 columns, nanoseconds per element:

columns read 1 2 4 6 8 12 16 20
ns / element 0.98 0.45 0.30 0.30 0.65 0.98 1.46 2.58

Flat at 0.30 ns/element up to six concurrent streams, then it climbs 8.6x by twenty. The hardware prefetcher tracks a bounded number of sequential streams; past that, every column is a fresh miss. Keep a system reading six or fewer components and struct-of-arrays is nearly free. Past eight, it starts losing to an interleaved layout — whose cost is flat at 5–10 ms regardless of how many fields it reads, because it drags the whole 160-byte record in either way.

This is the same lesson as the cell-size sweep in collision broadphase: the optimum is a wide flat basin, and the failure mode is being far outside it.

What does random access by entity id cost?

The received wisdom is that struct-of-arrays wins the loop and loses the lookup, because one entity's data is scattered across N arrays. Half true. Nanoseconds per random lookup, 200,000 lookups into 1,000,000 entities of 20 fields:

fields read 1 2 4 8 12 20
objects a[id] 24.5 25.3 35.6 62.9 96.5 135.7
SoA 0.9 3.2 10.9 27.3 48.5 91.5
AoS 7.5 8.9 14.7 24.9 33.5 54.0

Objects never won a single row. Not one. a[id] on an object array is two dependent cache misses — the pointer, then the object — and a 90 MB object heap gives you both. SoA wins up to eight fields, the interleaved layout wins above it, and the array of objects is second-worst everywhere and worst at the wide end. Storing entities in a Map instead of an array cost 126.3 ns per lookup against 50.0 ns for a[id] at six fields: a 2.5x tax for the convenience.

That was the measurement I expected to contradict me, and it did not.

What does it cost to spawn, destroy and filter?

Spawning 10,000 entities and swap-removing 10,000 from a base of 100,000, per frame — with the id-to-index map maintained for the typed-array layouts, which objects do not need because the reference is the handle:

ms
objects: push + swap-remove 0.925
SoA: append + swap-remove across 8 arrays 0.452
AoS: append + swap-remove, one contiguous copy 0.248

Objects lose again, even carrying the handicap. Iterating only the entities that have a given component, 1,000,000 total, p50 ms:

share matched objects, branch objects, list SoA mask SoA sparse set SoA packed
1% 10,032 7.217 0.055 0.812 0.019 0.004
10% 100,236 9.980 2.335 0.940 0.195 0.040
25% 250,218 5.806 4.682 1.749 0.252 0.104
50% 499,928 8.318 5.494 3.024 0.380 0.210
100% 1,000,000 6.106 6.860 0.501 0.738 0.415

Three things fall out. Branching over every entity costs the same whatever the share — you walk all million objects to find ten thousand. A packed archetype array is flat at 0.41 ns per matched entity at every share, where even the honest object approach (a separate array holding only the matching objects) cost 5.44 to 23.29 ns per matched entity — up to 58x — because those objects are still scattered over 100 MB. And the mask scan is worst at 50% (3.024 ms) and best at 100% (0.501 ms): that is branch misprediction, not memory, and it is a 6x swing on identical data.

Does archetype fragmentation actually hurt?

Barely. Splitting 500,000 matching entities into A contiguous chunks and iterating all of them:

chunks 1 8 128 2,048 8,192
entities/chunk 500,000 62,500 3,906 244 61
ns / entity 0.80 0.91 0.91 0.96 1.01

8,192 archetypes of 61 entities each cost 26% more than one contiguous run. Every ECS tutorial warns about archetype fragmentation; on this hardware the iteration cost of it rounds to nothing. Whatever makes a real ECS complicated, it is not this.

What actually makes object code slow in JavaScript?

Deleting a property from objects cost 22.9x, more than any memory layout change recovers

The same two-field move system over 1,000,000 objects, varying only the shapes:

object shapes ms vs monomorphic
one shape (all built from the same literal) 6.146 1.00x
one extra property added to all after creation 6.639 1.08x
two shapes (half gain a property) 6.404 1.04x
four shapes 7.067 1.15x
twelve shapes 35.358 5.75x
one delete per object (dictionary mode) 140.943 22.93x

Adding a property later is nearly free. Two or four shapes at a call site are nearly free — V8 handles polymorphic inline caches well. Twelve shapes goes megamorphic and costs 5.8x. And delete e.armor — the obvious way to remove a component from an entity — drops the object into dictionary mode and costs 22.9x, which is 140.9 ms against 2.6 ms for struct-of-arrays: 55x, and none of it is cache locality.

If your object-based game feels slow, measure shapes before you rewrite for an ECS. Attaching components by assigning properties at runtime is exactly how a codebase drifts from one hidden class to twelve, and it is a far larger effect than the layout you chose. The determinism argument for typed arrays holds too, for the reasons in deterministic simulation.

What this does not measure

No multithreading, no jobs, no SIMD — the whole case for Unity DOTS is that Burst vectorises these loops and schedules them across cores, and none of that exists here. No C#: struct layout, NativeArray and the absence of hidden classes make Unity a genuinely different machine, so treat the 184,000 as a shape, not a number to quote at your lead. No component add/remove causing archetype migration, which is the expensive operation in a real ECS and the one this article's fragmentation test deliberately sidesteps. One CPU, one architecture; the eight-stream prefetcher limit will differ elsewhere.

What does transfer: cost follows bytes touched, wide systems invert the SoA advantage, and pointer order matters more than anything. We size entity budgets in our Unity templates against numbers like these, the same way the tilemap storage measurements set the memory budgets. If you want the 2D animation side already solved, CST Animator is where that work went.

Check it yourself

Save as ecs.mjs and run node --max-old-space-size=8192 ecs.mjs. No dependencies. It reproduces both headline findings — the layout table and the reversal at 20 fields — in about 19 seconds.

// node ecs.mjs   — objects vs struct-of-arrays vs interleaved, and where it flips
const rng = a => () => { a|=0; a=(a+0x6D2B79F5)|0; let t=Math.imul(a^(a>>>15),1|a);
  t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; };
const DT = 1/60;

function ms(fn, work) {                      // p50, with inner repeats so samples are ms-scale
  const rep = Math.max(1, Math.round(4e6 / work));
  for (let i = 0; i < 3; i++) for (let r = 0; r < rep; r++) fn();
  const t = [], t0 = performance.now();
  while (t.length < 15 || (performance.now() - t0 < 800 && t.length < 41)) {
    const a = performance.now(); for (let r = 0; r < rep; r++) fn(); t.push((performance.now()-a)/rep);
    if (performance.now() - t0 > 800 && t.length >= 15) break;
  }
  return t.sort((a,b)=>a-b)[t.length >> 1];
}

const K = ['x','y','vx','vy','hp','energy','regen','armor'], F = K.length;
const mk = new Function('r', 'return {'+K.map(k=>`${k}:r()`).join(',')+'};');

// two systems over different fields: move (x,y,vx,vy) then regen (energy,regen)
const objSys = a => { const n=a.length;
  for (let i=0;i<n;i++){const e=a[i]; e.x+=e.vx*DT; e.y+=e.vy*DT;}
  for (let i=0;i<n;i++){const e=a[i]; e.energy+=e.regen*DT; if(e.energy>100)e.energy=100;} };
const soaSys = o => { const n=o.n,x=o.x,y=o.y,vx=o.vx,vy=o.vy,en=o.energy,rg=o.regen;
  for (let i=0;i<n;i++){x[i]+=vx[i]*DT; y[i]+=vy[i]*DT;}
  for (let i=0;i<n;i++){en[i]+=rg[i]*DT; if(en[i]>100)en[i]=100;} };
const aosSys = s => { const d=s.d,n=s.n;
  for (let i=0;i<n;i++){const b=i*F; d[b]+=d[b+2]*DT; d[b+1]+=d[b+3]*DT;}
  for (let i=0;i<n;i++){const b=i*F; d[b+5]+=d[b+6]*DT; if(d[b+5]>100)d[b+5]=100;} };

console.log('n\tobjects\tshuffled\tSoA\tAoS\tSoA speedup');
for (const n of [1000, 10000, 100000, 1000000]) {
  const r = rng(7), a = new Array(n); for (let i=0;i<n;i++) a[i] = mk(r);
  const A = ms(()=>objSys(a), n);
  const z = a.slice(), q = rng(9);
  for (let i=n-1;i>0;i--){const j=(q()*(i+1))|0; const t=z[i]; z[i]=z[j]; z[j]=t;}
  const Z = ms(()=>objSys(z), n);
  const s = {n}; for (const k of K) s[k] = new Float64Array(n);
  const r2 = rng(7); for (let i=0;i<n;i++) for (const k of K) s[k][i] = r2();
  const S = ms(()=>soaSys(s), n);
  const d = new Float64Array(n*F), r3 = rng(7); for (let i=0;i<n*F;i++) d[i] = r3();
  const I = ms(()=>aosSys({d,n}), n);
  console.log([n, A.toFixed(3), Z.toFixed(3), S.toFixed(3), I.toFixed(3),
               (A/S).toFixed(2)+'x'].join('\t'));
}

// the reversal: 20 fields, read 2 of them vs read all 20
const N = 1e6, G = 20, GK = [...Array(G)].map((_,i)=>'f'+i);
const mk20 = new Function('r', 'return {'+GK.map(k=>`${k}:r()`).join(',')+'};');
const r4 = rng(7), objs = new Array(N); for (let i=0;i<N;i++) objs[i] = mk20(r4);
const cols = []; { const q = rng(7);
  for (let f=0;f<G;f++){ const c = new Float64Array(N); for (let i=0;i<N;i++) c[i]=q(); cols.push(c); } }
const flat = new Float64Array(N*G); { const q = rng(7); for (let i=0;i<N*G;i++) flat[i]=q(); }
const gen = (S) => [
  new Function('a','N','let s=0;for(let i=0;i<N;i++){const e=a[i];s+='+[...Array(S)].map((_,i)=>`e.f${i}`).join('+')+';}return s;'),
  new Function('C','N','const '+[...Array(S)].map((_,i)=>`c${i}=C[${i}]`).join(',')+';let s=0;for(let i=0;i<N;i++){s+='+[...Array(S)].map((_,i)=>`c${i}[i]`).join('+')+';}return s;'),
  new Function('d','N','G','let s=0;for(let i=0;i<N;i++){const b=i*G;s+='+[...Array(S)].map((_,i)=>`d[b+${i}]`).join('+')+';}return s;')];
let sink = 0;
console.log('\nfields read (of 20, n=1,000,000)\tobjects\tSoA\tAoS');
for (const S of [2, 20]) {
  const [fo, fs, fa] = gen(S);
  const o = ms(()=>{sink+=fo(objs,N);}, N*S);
  const c = ms(()=>{sink+=fs(cols,N);}, N*S);
  const l = ms(()=>{sink+=fa(flat,N,G);}, N*S);
  console.log(`${String(S).padStart(2)}\t\t\t\t${o.toFixed(2)}\t${c.toFixed(2)}\t${l.toFixed(2)}`);
}
if (sink === 0.5) console.log('unreachable');

On the machine above it prints:

n	objects	shuffled	SoA	AoS	SoA speedup
1000	0.004	0.005	0.001	0.002	4.28x
10000	0.046	0.059	0.022	0.028	2.10x
100000	0.737	3.249	0.179	0.280	4.11x
1000000	13.239	49.512	1.806	3.961	7.33x

fields read (of 20, n=1,000,000)	objects	SoA	AoS
 2				14.73	0.73	7.97
20				11.27	42.91	7.17

Those differ a little from the tables above — the tables ran each entity count in its own process, which keeps a 300 MB object heap from taxing the typed-array runs that follow it. The direction and the magnitudes hold. Run it on your own hardware before quoting any absolute number here; the eight-stream cliff is the finding, the milliseconds belong to this laptop.