Equana

All articles
July 12, 2026·Max Schurigmemory-managementinterpretersarchitecture

Reference counting instead of GC: deterministic memory for a browser notebook

Why Equana's interpreter — hosted inside a garbage-collected runtime — deliberately uses reference counting for its arrays, and how views, temporaries, and free() work.

Equana's language interpreter is written in TypeScript, which means it lives inside one of the most heavily engineered garbage collectors in existence. And yet for array memory, it doesn't use it — every NDArray in Equana is managed by reference counting, freed deterministically the moment its last reference disappears. This post is about why a language hosted inside a GC'd runtime deliberately opts out of tracing garbage collection for its most important data, and what the machinery actually looks like.

Two heaps, two lifetimes

The previous post in this series covered Equana's memory model: all numerical data lives in a single shared linear memory (SharedArrayBuffer), and array values in the interpreter are handles — pointer, dtype, shape, strides — into that buffer. WASM kernels read and write it directly, zero-copy.

That design splits the world into two heaps. JS-backed values — scalars, strings, booleans, closures, collections — live in the ordinary JavaScript heap, and the host GC handles them the way it handles everything else. The interpreter doesn't track those at all. But array data lives in the shared buffer, which the JavaScript GC cannot see into. To V8, the whole multi-hundred-megabyte buffer is one opaque object that is trivially reachable. No tracing collector will ever free a matrix inside it, because from the GC's perspective there's nothing there to free.

Someone has to free that memory, and that someone is the interpreter.

Why reference counting wins here

The classic debate is tracing GC versus reference counting, and tracing usually wins in general-purpose runtimes. Four properties of this specific system flip the answer.

Determinism matters more than throughput. When a 4 GB matrix goes out of scope, it should be freed now — not at some future collection pause that may or may not arrive before the next big allocation. In a notebook, users routinely allocate a large fraction of available memory in a single cell; whether the previous cell's arrays are gone yet is the difference between running and crashing. It matters even more for GPU backends, where VRAM is scarce (a typical maxBufferSize is 256 MB) and "freed eventually" is functionally the same as leaked.

Refcounting is trivial in an AST-walker. Equana's interpreter is a pure AST-walking evaluator — no JIT, no bytecode. Every variable assignment, every function call, every scope exit is an explicit step in the evaluator where I already have code running. Incrementing or decrementing a counter at those points is a few lines. A tracing collector, by contrast, would need to enumerate all roots across interpreter environments, the evaluation stack, and closures — real machinery, with the pauses to go with it.

Cycles are structurally impossible. The textbook fatal flaw of reference counting is cyclic garbage. But Equana's handles are flat structs: pointer, dtype, shape, strides, backend. A handle references backend memory, never another handle — so reference cycles simply cannot form in the handle layer. The one classic reason to need a tracing collector is absent by construction. (JS-backed values can form cycles — a closure capturing itself, say — and those belong to the host GC, which handles cycles fine.)

The free operation is backend-uniform. Equana's arrays can live on different backends — WASM shared memory, WebGPU buffers, native memory via Tauri. The only thing that has to happen at refcount zero is backend.free(handle), and every backend already implements that. No backend needs to integrate with a garbage collector; the backends don't even know refcounting exists.

The tracked handle

The evaluator never holds raw backend handles. Every array value wraps its allocation in a TrackedHandle — this is the actual class from the interpreter:

export class TrackedHandle { refcount = 1 children = 0 freed = false constructor( readonly inner: MemoryHandle, readonly backend: AsyncBackend, readonly parent: TrackedHandle | null, ) {} }

Five things to know about it: inner is the raw allocation from backend.alloc(); refcount starts at 1 for a fresh allocation; parent is non-null only for views (more below); children counts live views pointing at this handle; and freed makes double-free impossible — release on a freed handle is a no-op, retain on one is a hard MemoryError.

The retain/release rules are the boring, load-bearing part:

  • Retain (refcount++): binding to a variable, passing as a function argument, storing in a struct field, adding to a collection.
  • Release (refcount--): reassigning a variable (the old value releases), scope exit (function return, loop end, branch end — all locals release), argument slots releasing when a function returns.
  • Free: when refcount hits 0 and no live views remain, backend.free() runs immediately.

In practice:

A = zeros(1000, 1000) # alloc, refcount = 1 B = A # retain, refcount = 2 B = nothing # release, refcount = 1 A = nothing # release, refcount = 0 → backend.free(), right now

There is no "collection" step anywhere. Memory pressure at any instant reflects exactly what's reachable at that instant.

Views: the part that took real design

Slicing in Equana returns a view — a new handle with its own shape, strides, and offset, sharing the parent's memory with no copy:

A = zeros(100, 100) B = A[1:50, :] # view — allocates nothing

Views complicate lifetimes in both directions. The view must not outlive the parent's memory, and the parent's memory must not be freed while views are watching it. The children counter solves the second problem: creating a view increments the parent's children, and a root handle whose refcount hits zero is not freed while children > 0 — the free is deferred until the last view releases. Only the owning root ever gets backend.free(); a view has no independent allocation to free, so releasing it just marks it freed and decrements the parent's counter, potentially triggering the deferred parent free.

The subtle case is nested views. If B is a view of A and you take C = B[1:25, :], the naive design makes C's parent B, giving you parent chains — and releasing a deep chain means walking it. Instead, view creation flattens to the root owner: C.parent is A, not B. The interpreter resolves to the root before constructing any view:

export function rootOf(h: TrackedHandle): TrackedHandle { let current = h while (current.parent !== null) { current = current.parent } return current }

Every view points directly at the handle that actually owns memory, so release is O(1) no matter how deeply you sliced.

Temporaries: the expression problem

The rules above cover variables. But most allocations in numerical code are never bound to a variable at all:

result = gemm(A, B) + gemm(C, D)

Evaluating this creates three arrays — two gemm results and the sum — and only one of them gets a name. Without extra machinery, the two intermediates leak.

The mechanism is an expression-scoped temporary list. Every allocation made while evaluating an expression is pushed onto the current expression's list. When the expression completes: the assigned result is retained, then every handle on the list is released, and anything at refcount zero is freed on the spot.

temporaries = [] t1 = gemm(A, B) # alloc, push t1 t2 = gemm(C, D) # alloc, push t2 t3 = add(t1, t2) # alloc, push t3 retain(t3) # result binds t3 → refcount = 2 release(t1) # 0 → freed release(t2) # 0 → freed release(t3) # 1 — result holds it → kept

The intermediates die at the end of the statement, not the end of the scope. For GPU workloads with a 256 MB buffer ceiling, this aggressive sweep is not an optimization — it's the difference between working and not.

The same list also solves WASM-allocated outputs. Kernels allocate result buffers by calling back into a host-exported allocator, and every such allocation lands on the current expression's temporary list automatically. The returned pointer is matched to its tracked handle when the kernel returns, and the standard sweep applies: assigned results survive, intermediates are freed. WASM binaries never think about deallocation at all.

Escape hatches and the big red switch

For users juggling buffers near the memory ceiling, there's an explicit free():

A = create(Float64, 10000, 10000, "webgpu") # ... use A ... free(A) # backend memory released immediately A[1, 1] # MemoryError: handle was freed

free() force-frees regardless of refcount — other variables referencing the same handle become dangling, and touching them raises a runtime error rather than reading freed memory (the freed flag makes this safe and detectable). It's idempotent, and calling it on a view frees the root parent, invalidating all sibling views. Sharp tool, clearly labeled.

The other bulk path is kernel restart: every backend implements dispose(), which tears down all allocations at once — the WASM backend clears its handle map, WebGPU destroys its buffers and staging pool, the Tauri backends free native and device memory over IPC. Restart the kernel and everything is gone, deterministically.

And to be explicit about the boundary: closures, environments, strings, structs without handles, and any cycles among JS-backed values remain the host GC's job. Refcounting governs exactly one thing — backend array memory — and delegates everything else.

Trade-offs, honestly

Reference counting isn't free. Every assignment and scope exit in a hot loop touches counters, and an AST-walking interpreter pays that cost in interpreter time (mitigated by the fact that the interpreter is orchestration — the heavy work is inside kernels, which don't refcount anything). There's no cycle collector, which is fine today because handle cycles are structurally impossible, but it's a constraint the design must preserve: if handles ever gain the ability to reference other handles, the whole argument changes. And Equana is in alpha — the retain/release rules are exercised by tests and real notebooks, but I still find lifecycle edge cases (view-of-destructured-value lifetimes were a recent one).

The payoff is a property I now consider non-negotiable for a numerical notebook: memory behavior you can predict by reading the code. A matrix dies exactly where its last reference does. No pauses, no pressure heuristics, no "try clicking restart." For a tool whose users regularly allocate half their RAM before their second coffee, deterministic beats fast-on-average.

You can watch all of this behave at equana.dev (alpha, sign-in required), and the interpreter is on npm as equana. The companion posts cover the language design and the zero-copy WASM memory model this one builds on.

Next: Running LAPACK-style kernels in the browser: zero-copy WASM via SharedArrayBuffer