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 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
deleteput 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.
How the three layouts are built
Same simulation, same seeded data, three storages.
- Array of objects.
new Array(n)of object literals created from one generated constructor, so allnshare a single hidden class:{x, y, vx, vy, hp, energy, regen, armor}, eight doubles. - Struct-of-arrays (SoA). One
Float64Array(n)per field, eight arrays, hoisted into locals before the loop. - Array-of-structs (AoS). One flat
Float64Array(n * 8), interleaved, stride 8; entityi's fields live atd[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?
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.