WebAssembly Linear Memory Growth in the Browser

WebAssembly memory has one property that makes it unlike everything else on this site: it is a ratchet. Every page it acquires it keeps until the instance is discarded. This page belongs to typed arrays, buffers and external memory inside JavaScript Memory Fundamentals & Runtime Mechanics.

Symptom Root Cause Immediate Action
Memory stays at the peak after a big job Linear memory cannot shrink Discard and re-instantiate the module
TypeError on a typed array after a call Growth detached the previous memory.buffer Re-create views after any call that may grow
Tab becomes unresponsive on a large file Memory grew until the whole page was under pressure Declare a maximum and chunk the input
Memory grows slowly across many small jobs The module’s allocator never returns pages Recycle the instance on a job or size threshold
Page count differs between browsers Different allocator behaviour inside the module Measure page count, not process memory

Root Cause: A Ratchet, Not a Heap

A WebAssembly.Memory is a contiguous region addressed from zero, sized in 64 KB pages. The module’s own allocator — malloc compiled to Wasm, or whatever the source language provides — carves that region up. When it runs out, it executes memory.grow, the browser commits more pages, and the region gets larger.

There is no inverse. The specification provides memory.grow and nothing that returns pages. Freeing inside the module marks the space reusable by the module, which is genuinely useful — a second job of the same size reuses the pages rather than growing again — but the browser’s commitment is permanent for the instance’s lifetime.

So peak input size becomes steady-state memory. Process one 400 MB document and the instance holds roughly 400 MB of pages for as long as the page lives, whatever it does afterwards. On a desktop that is an annoyance; on a phone, where the whole tab budget may be 256 MB, it is the end of the session.

Growth also detaches views. memory.buffer returns an ArrayBuffer over the current region; growing replaces it, and every typed array built over the old one becomes detached — byteLength zero, reads throwing. Code that caches a Uint8Array at start-up and uses it after a growing call is the single most common WebAssembly integration bug.

Peak input becomes steady state Across three jobs the instance grows from sixteen megabytes to sixty-four for a medium job and to four hundred for a large one. After the large job finishes, memory stays at four hundred megabytes for the rest of the session even though subsequent jobs need only sixteen. Three jobs, one instance — the third job sets the floor forever 0 256 MB 512 MB session time → small · 16 MB medium · 64 MB large · 400 MB later jobs need 16 MB; it still holds 400 with instance recycling memory returns to 16 MB after the large job

Step-by-Step Fix

  1. Measure in pages, not megabytes. Command: instance.exports.memory.buffer.byteLength / 65536. Expected: a page count you can log at job boundaries and chart.
  2. Declare a maximum. Command: new WebAssembly.Memory({ initial: 256, maximum: 4096 }). Verification: a runaway allocation throws inside the module instead of pressurising the tab.
  3. Chunk oversized inputs. Verification: peak page count becomes a function of chunk size, flat across input sizes.
  4. Recycle after large jobs. Drop the instance and instantiate a fresh one when the page count exceeds a threshold. Verification: the page count returns to its initial value.
  5. Re-create views after every growing call. Verification: no TypeError on detached buffers under a stress test that forces repeated growth.

Command & Code Reference

Instantiating with an explicit maximum and a helper that always reads the current buffer.

// initial and maximum are in 64 KB pages: 256 pages = 16 MB, 4096 = 256 MB.
const memory = new WebAssembly.Memory({ initial: 256, maximum: 4096 });
const { instance } = await WebAssembly.instantiateStreaming(
  fetch('/codec.wasm'),
  { env: { memory } }
);

// NEVER cache this — growth replaces the underlying ArrayBuffer and detaches
// every view built over the old one.
const view = () => new Uint8Array(memory.buffer);

function writeInput(bytes, ptr) {
  view().set(bytes, ptr);          // fresh view, current buffer, every time
}

A recycling wrapper that trades a few milliseconds of instantiation for a bounded steady state.

const MAX_PAGES_BEFORE_RECYCLE = 1024;   // 64 MB

function createCodec(wasmUrl) {
  let instancePromise = instantiate(wasmUrl);

  async function instantiate(url) {
    const memory = new WebAssembly.Memory({ initial: 256, maximum: 4096 });
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch(url), { env: { memory } });
    return { instance, memory };
  }

  return {
    async run(input) {
      const { instance, memory } = await instancePromise;
      const result = instance.exports.process(input);
      const pages = memory.buffer.byteLength / 65536;
      if (pages > MAX_PAGES_BEFORE_RECYCLE) {
        // Dropping the instance is the only way to give the pages back.
        instancePromise = instantiate(wasmUrl);
      }
      return result;
    },
  };
}

Chunking, so one large input never sets the high-water mark in the first place.

const CHUNK = 4 * 1024 * 1024;   // 4 MB — peak memory tracks this, not the file

async function processLargeFile(codec, file) {
  const results = [];
  for (let offset = 0; offset < file.size; offset += CHUNK) {
    const slice = file.slice(offset, offset + CHUNK);
    const bytes = new Uint8Array(await slice.arrayBuffer());
    results.push(await codec.run(bytes));
    // `bytes` is collectable here; the module reuses its own pages next round.
  }
  return results;
}
Peak memory against input size Processing a whole file at once makes peak memory rise linearly with input size, reaching four hundred megabytes for a four hundred megabyte file. Chunked processing holds peak memory flat at about twenty megabytes regardless of input size. Peak linear memory as input size grows 0 200 MB 400 MB 10 MB 100 MB 250 MB 400 MB input size → whole file → peak = input size 4 MB chunks → flat at ~20 MB

Verification & Regression Prevention

Verify by logging the page count at every job boundary rather than by watching the browser’s memory readout, which mixes in everything else on the page. The sequence to look for is: baseline, peak during the job, and back to baseline after recycling. A page count that never returns is the whole bug in one number.

For prevention, add an assertion to the module’s integration test: run a large job, then a small one, and assert the page count after the small job is within a page or two of the starting value. That fails immediately if someone removes the recycling logic or raises the threshold past the point where it does anything. Pair it with a stress test that forces repeated growth and asserts no detached-buffer errors, which covers the cached-view bug at the same time.

The page-count assertion The test records two hundred and fifty-six pages at baseline, six thousand four hundred at the peak of a large job, and two hundred and fifty-six again after recycling and a small job. The final assertion compares the last value against the first. One assertion covers both the ratchet and the recycling logic baseline 256 pages 16 MB peak, large job 6 400 pages 400 MB — allowed after recycling 256 pages assertion passes Without recycling the third box reads 6 400 and the assertion fails — which is exactly the production behaviour that turns one large upload into a session that never recovers. Run the same test with a forced growth loop to cover detached views in the same pass.

FAQ

Can WebAssembly memory be shrunk?

No. The memory.grow instruction adds pages and there is no corresponding shrink. Freeing memory inside the module returns it to the module’s own allocator for reuse, but the linear memory the browser has committed stays committed for the lifetime of the instance. Discarding the instance is the only way to give the pages back.

Does a detached ArrayBuffer after growth cause bugs?

It causes a very common one. Growing the memory detaches any existing view over memory.buffer, so a cached Uint8Array taken before the growth throws or reads zeroes afterwards. Re-create views from memory.buffer after every call that might grow, or wrap access in a helper that always reads the current buffer.

Should I set a maximum on WebAssembly.Memory?

Yes for anything long-lived. Without a maximum, a runaway allocation grows until the tab is under memory pressure and the whole page suffers. With one, the growth fails inside the module where you can catch it and report a useful error to the user.