Viscosity
Review
Part VII · Building Viscosity · Day 27

105. Growing collections

An array of objects grows when you push. A Float64Array never does. Here is how Viscosity’s container fakes it, and why doubling makes push cheap on average.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Tell a collection’s capacity apart from its length, and say why spare slots need care.
  • Explain how the generated container grows its typed columns, and why its plain columns need no copying.
  • Show that doubling the capacity makes push cost a constant amount of copying on average.
  • Read the construct benchmark and say what it does and doesn’t measure.

Push into a fixed box

Here is a program from Viscosity’s test corpus, growth.js. Nobody knows how many elements ps will hold until the while loop has run:

const ps = [];
let n = 0;
while (n < 5000) {
  ps.push({ a: n, b: n % 7 });
  n++;
}

Viscosity turns ps into a struct of arrays with two Float64Array columns. But a Float64Array made with new Float64Array(n) has a fixed length, and it starts out full of zeros[1]. There is no push on it. So how does the compiled program keep pushing?

Intuition

A restaurant expecting an unknown number of guests doesn’t add one chair each time someone walks in. It sets a table for 16. When that fills up, it moves everyone to a table for 32. Moving is a hassle, but it happens rarely, and most guests just sit down.

Capacity and length

The container from chapter 103 keeps two numbers. cap is how many slots each column has. length is how many of them hold real elements. Step through a column filling up:

Step throughCapacity, length and doubling
push(v) {
const n = this.length;
if (n === this.cap) this.grow();
this.a[n] = v;
return (this.length = n + 1);
}
grow() {
const cap = (this.cap *= 2);
const t = new Float64Array(cap);
t.set(this.a); this.a = t;
}
A column's slots: used ones filled, spare ones dashed; growing copies the old slots into a twice-as-big arraya: cap 40000length 0capacity 4pushes 0copies 04 spare
A new container: capacity 4, length 0. The Float64Array already has 4 slots, all zero. Capacity is how many slots exist; length is how many are in use.
1/9
Definition

A collection’s capacity is the number of slots it has allocated; its length is the number in use. Slots from length up to capacity are spare: they exist in memory, but they aren’t elements. When a push finds length === capacity, the collection grows: it allocates bigger storage and copies the old elements into it.

The real container

Here is what Viscosity really generates for growth.js, reformatted to fit the screen (the compiler writes each method on one line):

class __Vsc_ps {
  constructor(cap = 16) {
    this.length = 0;
    this.cap = cap;
    this.a = new Float64Array(cap);
    this.b = new Float64Array(cap);
  }
  push(v0, v1) {
    const n = this.length;
    if (n === this.cap) this.grow();
    this.a[n] = v0;
    this.b[n] = v1;
    return (this.length = n + 1);
  }
  grow() {
    const cap = (this.cap *= 2);
    let t;
    t = new Float64Array(cap);
    t.set(this.a); this.a = t;
    t = new Float64Array(cap);
    t.set(this.b); this.b = t;
  }
}

The push in the loop becomes ps.push(n, n % 7). grow() doubles cap, and gives each column a new Float64Array of that size. set copies every value of the old column into the start of the new one[2]. The old column has no references left, so the garbage collector will reclaim it. And push returns the new length, just as Array.prototype.push does, in case the program uses it.

Not every column is typed. When a field may hold strings or objects, chapter 103’s container gives it a plain array column instead, and grow() leaves that column alone. Writing this.name[n] = v at index length simply appends to a plain array: the JavaScript engine grows it by itself.

Why doubling

Growing could add a fixed number of slots, say 16, each time. That sounds thriftier. Count the copies instead:

Try itDoubling vs adding 16
function copies(n, next) {
  let cap = 16, c = 0;
  for (let len = 0; len < n; len++) {
    if (len === cap) {
      c += len;
      cap = next(cap);
    }
  }
  return c;
}
const dbl = (c) => c * 2;
const add = (c) => c + 16;
for (const n of [1000, 10000, 100000])
  console.log(n, copies(n, dbl),
    copies(n, add));

With doubling, building 100,000 elements copies about 131,000 values in all. Adding 16 at a time copies more than 312 million. The reason is simple. When doubling, each grow copies 16 more values than all the earlier grows put together: 16, then 32, then 64. So the total is always less than the final capacity, which is less than twice the length. Adding 16 instead copies 16, 32, 48, 64 and so on, a sum that grows with the square of the length.

Definition

Amortized growth spreads the cost of the rare, expensive grows over all the cheap pushes. With doubling, n pushes copy fewer than 2n values in total, so one push costs a constant amount on average, even though an unlucky push copies everything. Growing by a constant amount instead makes n pushes cost time proportional to n².

For growth.js itself, the counter below follows the container from capacity 16: 9 grows, 8,176 values copied per column, and a final capacity of 8,192 for 5,000 elements.

Try itgrowth.js, counted
let cap = 16, len = 0;
let grows = 0, copies = 0;
for (let k = 0; k < 5000; k++) {
  if (len === cap) {
    copies += len; // t.set(old)
    cap *= 2;
    grows++;
  }
  len++;
}
console.log(grows, copies, cap);
console.log((copies / len).toFixed(2));

What it costs

The book’s construct suite times building a whole collection of 6-field particles from raw values:

Building 100,000 particles
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 169.95 ns, p95 274.22 ns per elementarray of objects169.9packed Float64Array: median 5.28 ns, p95 6.18 ns per elementpacked Float64Array5.3 32.2× fasterstruct of arrays: median 10.24 ns, p95 15.49 ns per elementstruct of arrays10.2 16.6× fasterAoSoA, blocks of 8: median 44.47 ns, p95 62.08 ns per elementAoSoA, blocks of 844.5 3.8× faster
Table view
layoutmedian nsp95 ns
array of objects169.95274.22
packed Float64Array5.286.18
struct of arrays10.2415.49
AoSoA, blocks of 844.4762.08

Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.

Writing numbers into columns is far cheaper than allocating one object per element.

Read it carefully. The harness knows n in advance, so its columns are created at the right size and never grow. What the chart shows is that filling columns avoids an allocation per element. Doubling adds fewer than two copies per value on top, which is small next to an allocation, but this run doesn’t time it separately.

Under the hood

The spare slots are the subtle part. In the original program ps[100].a, one past the end, throws a TypeError, because ps[100] is undefined. After the rewrite the column may have 128 slots, and ps.a[100] would quietly return 0. So when Viscosity can’t prove an index is in bounds, it wraps it in a check that compares the index with the container’s length and throws the same TypeError. The corpus program out_of_bounds.js tests exactly that. Literal initializers get a capacity too: const ps = [{...}, {...}] becomes __Vsc_ps.of(...), which starts at the number of records, or 16 if that is larger.

Your turn

ExerciseA column that grows

Finish push(v) for a one-column container. It starts with capacity 4. When the column is full, grow it the way Viscosity does: a new Float64Array of twice the capacity, the old values copied in with set, and copies increased by the number of values copied. Then store v at index len and return the new length.

function makeCol() {
  return {
    len: 0, cap: 4, copies: 0,
    data: new Float64Array(4),
    push(v) {
      // your code here
    },
  };
}

const c = makeCol();
for (let i = 0; i < 5; i++) c.push(i);
console.log(c.len, c.cap, c.copies);
Extra practiceCount the copies of any policy

Write copiesFor(n, next): start with capacity 16 and length 0, push n times, and whenever a push finds the collection full, add the current length to a copy count and set the capacity to next(capacity). Return the total copies.

function copiesFor(n, next) {
  // your code here
  return 0;
}

const dbl = (c) => c * 2;
console.log(copiesFor(5000, dbl));

What you learned

  • A collection has a capacity (slots allocated) and a length (slots used). Spare slots are real memory, full of zeros, so bounds checks must compare with length.
  • Viscosity’s container grows by doubling cap and copying each typed column with set(). Plain array columns grow by themselves.
  • Amortized growth: with doubling, n pushes copy fewer than 2n values in total, so a push is cheap on average. Growing by a constant is quadratic.
  • The construct benchmark shows filling columns beats allocating objects; it doesn’t measure growth, because its sizes are known.

Check your understanding

0/6
  1. 1.

    A container has cap 32 and length 20. What happens on the next push?

  2. 2.

    Why does Viscosity’s grow() call set on typed columns but do nothing for plain array columns?

  3. 3.

    Starting at capacity 16 and doubling, how many values are copied per column while pushing 5,000 elements?

  4. 4.

    Which statement about amortized growth is true?

  5. 5.

    In the original program ps.length is 100 and ps[100].a throws. After the rewrite the column has 128 slots. What keeps the behaviour the same?

  6. 6.

    The construct benchmark shows SoA building 100,000 particles much faster than an array of objects. What does that tell you?

References

  1. [1] MDN contributors. TypedArray. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray (checked 2026-10-31)
  2. [2] MDN contributors. TypedArray.prototype.set(). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/set (checked 2026-10-31)