Memory Management, Stateful Handles & Garbage Collection
WebAssembly runs in a sandboxed linear byte array (WebAssembly.Memory). Managing memory safely across the language boundary between Nim and JavaScript/TypeScript requires clear rules for allocation, lifetime tracking, and cross-thread transfers.
NimTS provides a zero-leak memory architecture built on top of Nim's deterministic ARC/ORC engine.
1. Nim's Memory Engine in WebAssembly: ARC / ORC
Modern Nim (Nim 2.0+) compiles by default with ARC (--mm:arc) or ORC (--mm:orc).
No Stop-the-World GC Pauses
Unlike traditional garbage-collected languages (Java, Go, older Nim) or the JavaScript host engine, ARC does not run a background mark-and-sweep collector.
- Deterministic Scope-Based Reclamation: Memory reclamation is based on reference counting with compiler-injected move semantics and destructors (
=destroy). - Immediate Free: When a string, sequence, or object falls out of scope, its memory is freed immediately.
- Predictable Real-Time Latency: Audio synthesis (AudioWorklet), 60/120 FPS games, physics simulations, and WebGPU pipelines never suffer from unexpected GC stutter.
- WASI Allocator Integration: With
-d:useMalloc, Nim routes all allocations through standard Cmallocandfreeinwasi-sysroot(dlmalloc), cleanly sharing a single heap.
2. Ephemeral Call Lifecycle (Zero-Leak Calls)
For standard function calls passing primitives, strings, and byte arrays:
- Primitives (
int32,float32,bool): Passed directly in WebAssembly CPU registers. Zero heap allocation. - Input Strings & Buffers: JavaScript allocates a temporary buffer in WASM memory via
nimts_alloc. Nim reads the buffer, and the buffer is freed before or immediately after the call. - Return Strings &
seq[byte]: Nim allocates the return buffer viaallocStringResultorallocBytesResult, packing the pointer and byte length into a 64-bit register (uint64). The TypeScript wrapper copies the result into native JavaScript types and immediately invokesnimts_free. - Leak Risk: 0%. All ephemeral memory is reclaimed synchronously within the call.
3. Stateful Handles (ref object)
Passing raw pointers of heap-allocated Nim data structures to JavaScript is unsafe: it exposes applications to use-after-free bugs, pointer corruption, and prevents Nim's ARC from tracking references.
NimTS solves this with Opaque Stateful Handles:
# src/synth.nim
type
AudioEngine* {.exportts.} = ref object
sampleRate*: float32
volume*: float32
proc createEngine*(sampleRate: float32): AudioEngine {.exportts.} =
AudioEngine(sampleRate: sampleRate, volume: 1.0'f32)
proc setVolume*(engine: AudioEngine, vol: float32) {.exportts.} =
if engine != nil:
engine.volume = volHow Handles Work
- Handle Allocation: When
createEnginereturns an instance, NimTS assigns a monotonic 32-bitHandleId, registers the object in an internal handle table, and increments Nim's reference count viaGC_ref(obj). - TypeScript Class Wrapper: The generated TypeScript code exports a class wrapping the handle:typescript
export class AudioEngine { readonly handleId: number; constructor(handleId: number); dispose(): void; } - Handle ID Passing: When
setVolume(engine, 0.707)is called, the wrapper extractsengine.handleId. The WASM runtime looks up theAudioEnginepointer from the table with strict type safety.
Deterministic Release (.dispose())
For deterministic cleanup in performance-critical applications:
const engine = createEngine(48000.0);
setVolume(engine, 0.707);
// Explicit immediate cleanup
engine.dispose();When .dispose() is called:
nimts_destroy_handle(handleId)is called in WebAssembly.- Nim calls
GC_unrefon the tracked instance, allowing Nim's ARC/ORC to reclaim the memory immediately. - The TypeScript wrapper zeroes out
handleIdto prevent use-after-free. Calling.dispose()multiple times is safe and idempotent.
Automatic Fallback via FinalizationRegistry
If a developer forgets to call .dispose(), NimTS provides an automatic safety net:
The TypeScript runtime registers every active handle with JavaScript's native FinalizationRegistry. When the JavaScript garbage collector reclaims the JS wrapper object, the finalizer callback invokes nimts_destroy_handle(handleId) in the background, releasing the Nim memory.
4. Multi-Threaded Worker Memory: Bidirectional Zero-Copy Transfers
In @axiomantic/nimts/worker and Vite ?worker mode, WebAssembly runs on a dedicated background OS thread.
To maximize throughput when processing large binary payloads (audio buffers, physics meshes, tensor batches), NimTS implements bidirectional zero-copy buffer detachment:
How It Works:
- Main → Worker: When you pass an
ArrayBufferorTypedArrayto a worker method,extractTransfers(args)automatically identifies the underlying buffer and adds it to thepostMessagetransfer list. The caller's buffer is detached / neutered on the main thread and transferred to the worker in time with zero byte copying. - Worker → Main: When the worker returns an
ArrayBufferorTypedArray, the worker runtime callsextractTransfers(result). The buffer is detached in the worker thread and transferred back to the main thread Promise. - Streaming → Main: For channels or closure iterators streaming across a
MessagePort, each emitted item is transferred with zero copy.
TIP
If you need to retain a copy of the buffer on the sender thread instead of detaching it, pass transferBuffers: false in spawnWorker options:
const worker = await SynthWorker.spawn({ transferBuffers: false });5. Memory Leak Risks & How to Prevent Them
We audited NimTS for common memory failure modes (verified by adversarial test suites NEG-01 through NEG-06). Here are the real-world hazards and how NimTS handles them:
| Hazard | The Pitfall | How NimTS Handles It | Best Practice |
|---|---|---|---|
| High-Churn Handles | Creating millions of ref object instances without calling .dispose(). Relying solely on FinalizationRegistry can cause memory pressure because JS GC cycles run lazily. | Handled via fallback finalizers, but cannot force the host V8/browser GC to collect wrappers immediately. | Call .dispose() explicitly on ref object instances when finished (e.g. inside finally blocks). |
| Cyclic References | In pure --mm:arc, circular references between ref object instances (A → B → A) cannot be freed by reference counting alone. | Nim's --mm:orc adds a cyclic collector to ARC that tracks and reclaims cyclic graphs. | If your Nim data model uses circular parent/child pointer graphs, compile with --mm:orc. |
| Abandoned Streams | Breaking early out of a for await (const chunk of channel) loop without draining the channel. | Handled! When the loop breaks, the generator .return() method is called automatically, which closes the channel and releases handle table slots (verified by test NEG-01). | Use standard for await...of loops—early break is fully safe. |
| Detached Buffer Trap | When WebAssembly linear memory grows (memory.grow), the underlying ArrayBuffer is neutered. Existing typed array views throw a TypeError. | Handled! The NimTS runtime never caches raw ArrayBuffer references; accessors dynamically resolve getMemory().buffer on demand. | Use NimTS helpers (readBytes, writeToGpuBuffer) rather than saving long-lived references to wasm.memory.buffer. |
| Channel Backpressure | A producer thread pushes data faster than the consumer can process, causing unbounded memory growth. | Handled! NimTsChannel[T] has a fixed circular buffer capacity. When full, trySend rejects additional items, applying explicit backpressure rather than silently allocating memory. | Specify appropriate channel buffer capacities (e.g. newChannel[string](32)). |
6. Hardware Memory Alignment (WebGPU)
WebGPU dynamic uniform buffers require memory offsets aligned to 256 bytes (minUniformBufferOffsetAlignment). Standard malloc only guarantees 8- or 16-byte alignment.
NimTS provides hardware-aligned memory allocators in src/nimts/memory.nim:
nimts_alloc_aligned(size, 256): Allocates linear memory aligned to any power-of-two boundary using a prefix header storing the raw base pointer.GpuBufferSlice[T]: A typed wrapper that guarantees WebGPU alignment and provides zero-copydevice.queue.writeBuffertransfers into GPU staging buffers.
For complete WebGPU compute examples, see the WebGPU & GPU Compute Guide.