How to Fix: Wasm GC doesn’t seem to collect garbage which results in `GC heap out of memory` errors

8 min read

Wasm GC heap out of memory in Wasmtime: why Kotlin/Wasm guests keep growing and how to fix it

If your Kotlin-compiled WebAssembly module runs under Wasmtime with Wasm GC enabled but still crashes with GC heap out of memory, the problem is usually not that garbage collection is “missing.” The real issue is that the runtime cannot reclaim objects that are still reachable through host-held references, long-lived store state, or an embedding pattern that prevents the collector from seeing objects as dead.

With Wasm GC, collection only happens when values in the Wasm heap are no longer reachable from the module, the runtime, or the embedding host. In practice, a Kotlin/Wasm guest embedded in Wasmtime can keep allocating if externrefs, exported closures, instance state, or store-scoped handles are retained longer than expected. This is especially easy to trigger in host applications that cache functions, reuse a single store forever, or accidentally pin guest values in host-side data structures.

Understanding the Root Cause

Wasmtime support for Wasm GC means the runtime understands GC-managed Wasm types such as structs and arrays. That does not mean every object will be collected immediately or that all host integration patterns are safe by default. Collection depends on reachability analysis. If the host keeps references alive, the collector must treat those values as live.

The most common causes behind this issue are:

  • Long-lived Store usage: if you instantiate and execute Kotlin/Wasm code inside one store for the entire application lifetime, transient objects may accumulate longer than expected, especially if host references keep parts of the object graph alive.
  • Host-retained references: exported functions, callback objects, or values passed through host APIs can keep Wasm heap objects reachable. Even if your guest code stops using them, the host may still be holding them indirectly.
  • Embedding patterns that pin instances: caching instances, closures, or typed handles globally can prevent the collector from reclaiming associated GC objects.
  • Delayed or insufficient collection opportunities: GC may not run at the moment you expect. If the workload allocates aggressively and the embedding never drops roots, the heap can grow until Wasmtime reports an out-of-memory condition.
  • Misinterpreting Wasm GC as host-memory GC: Wasm GC manages GC-backed Wasm objects, but host-side Rust allocations, custom resource wrappers, and leaked handles are still your responsibility.

For Kotlin/Wasm specifically, the generated guest may create many short-lived objects during string handling, collection operations, coroutine scheduling, or callback interop. If your embedder stores any Wasm-related handles beyond a call boundary, those objects can remain reachable and never become collectible.

Step-by-Step Solution

The fix is usually a combination of upgrading Wasmtime, using the correct config, dropping references aggressively, and scoping stores/instances so GC roots can disappear.

1. Verify Wasmtime and feature configuration

First, ensure you are actually running a version of Wasmtime with the relevant Wasm GC support and that your engine configuration enables the features your guest requires.

use wasmtime::{Config, Engine, Result};
fn build_engine() -> Result<Engine> {
    let mut config = Config::new();

    // Enable the proposals your Kotlin/Wasm guest may require.
    config.wasm_gc(true);
    config.wasm_reference_types(true);
    config.wasm_function_references(true);

    Engine::new(&config)
}

If you are using a CLI or wrapper around Wasmtime, verify that the effective runtime flags match your code. A frequent mistake is assuming a library default enables everything required by the module.

2. Avoid keeping one Store alive forever for unbounded workloads

A Store can become the lifetime boundary for many runtime-managed values. If you run many guest requests inside the same store, stale references are more likely to accumulate. Prefer a shorter-lived store per task, request, session, or batch when isolation is acceptable.

use wasmtime::{Engine, Module, Store, Instance, Result};
fn run_guest_once(engine: &Engine, module: &Module) -> Result<()> {
    let mut store = Store::new(engine, ());
    let instance = Instance::new(&mut store, module, &[])?;

    let run = instance.get_typed_func::<(), ()>(&mut store, "run")?;
    run.call(&mut store, ())?;

    // store and instance drop here, removing host-side roots tied to this execution scope
    Ok(())
}

If your architecture currently uses a singleton store, refactor so object lifetimes align with workload boundaries. This is often the single biggest improvement for GC heap out of memory failures.

3. Drop exported functions, instances, and host caches as soon as possible

Even if the guest finishes execution, values can remain reachable if the host caches them. Review any global maps, callback registries, or service containers that hold Wasm-derived handles.

use std::collections::HashMap;
use wasmtime::{Func, Store};
struct GuestCache {
    callbacks: HashMap<String, Func>,
}

impl GuestCache {
    fn clear(&mut self) {
        self.callbacks.clear();
    }
}

Key rule: do not keep Func, instance handles, or reference-like wrappers alive longer than the guest execution really needs. If you must cache metadata, cache plain host data instead of live Wasm handles.

4. Be careful with host callbacks and externref-like patterns

Interop layers can unintentionally create cycles or long-lived reachability chains. If the guest passes objects into the host and the host stores them for callbacks, queues, or async execution, those objects stay live. Replace retained guest references with lightweight IDs whenever possible.

// Prefer storing plain identifiers instead of guest-owned references.
struct PendingJob {
    job_id: u64,
    description: String,
}

If your host API currently stores guest values directly, redesign the boundary so the guest can re-resolve state on demand instead of the host pinning it indefinitely.

5. Re-instantiate for batch workloads that produce heavy allocation churn

Kotlin/Wasm code that performs repeated high-allocation operations can be safer to run in fresh instances rather than one hot instance forever. This gives the collector a clean boundary and makes memory growth easier to reason about.

fn process_batch(engine: &Engine, module: &Module, items: usize) -> anyhow::Result<()> {
    for _ in 0..items {
        let mut store = Store::new(engine, ());
        let instance = Instance::new(&mut store, module, &[])?;
        let run = instance.get_typed_func::<(), ()>(&mut store, "run")?;
        run.call(&mut store, ())?;
    }
    Ok(())
}

This approach may trade some startup overhead for predictable memory behavior. In production embedders, that tradeoff is often worth it.

6. Inspect your embedding for logical leaks, not just runtime bugs

Before assuming a Wasmtime defect, test whether memory growth disappears when:

  • you create a fresh store per run,
  • you stop caching exported functions,
  • you remove host callback retention,
  • you destroy the instance after each request.

If those changes fix the issue, the root problem is most likely host-side reachability, not missing GC support.

7. Add a minimal reproduction and compare patterns

Create a reduced test with one module, one exported entrypoint, and no host-side caching. Then add features back one by one:

  1. baseline run with fresh store and instance,
  2. reuse instance only,
  3. cache exported function,
  4. add callback registry,
  5. add async queue or scheduler.

This isolates the exact retention pattern causing the heap to grow.

for i in 0..1000 {
    run_guest_once(&engine, &module)?;
    if i % 100 == 0 {
        eprintln!("completed {} runs", i);
    }
}

If the minimal version stays healthy but the real embedder fails, the bug is almost certainly in lifecycle management around the guest.

8. Keep dependencies current

Use a modern Wasmtime release and keep an eye on changelogs and issue trackers through the official Wasmtime repository. Early support for evolving proposals can improve significantly across versions, and behavior around GC integration may continue to mature.

Common Edge Cases

  • Async host runtimes: background tasks may retain store-bound functions or values long after the original request completes. Audit spawned tasks and channel payloads carefully.
  • Global singleton registries: a static map of callbacks or instances can keep the entire guest object graph alive.
  • Repeated string marshaling: Kotlin/Wasm workloads with frequent host-guest string conversion can create intense allocation pressure, making retention bugs appear faster.
  • Mixed memory assumptions: some developers expect Wasm GC to manage all memory related to the guest, but Rust-side allocations, buffers, and resource wrappers can still leak independently.
  • Instance reuse across tenants: multi-tenant embeddings often reuse a single instance or store to save startup time, but that makes memory isolation and collection much harder.
  • Reference cycles through callbacks: the guest stores a host callback, and the host stores a guest-derived function or reference. Even if indirect, this can extend lifetimes unexpectedly.

FAQ

Does Wasmtime supporting Wasm GC guarantee Kotlin/Wasm memory will always be reclaimed promptly?

No. Wasm GC support means the runtime can manage GC-backed Wasm objects, but reclamation still depends on whether objects are unreachable. If your host keeps roots alive, collection cannot free them.

Why does creating a new Store often fix GC heap out of memory errors?

A fresh Store creates a clean lifetime boundary. When the store is dropped, host-visible roots tied to that store also disappear, allowing all associated guest state to be reclaimed. This is why per-request or per-batch stores often stabilize memory usage.

How can I tell whether this is a Wasmtime bug or a host embedding leak?

Build a minimal reproduction with no caching and a short-lived store. If memory stays stable there but grows in your full application, the cause is likely your embedding lifecycle. If the minimal reproduction still fails on a current Wasmtime version, open an issue with the reduced test case and environment details.

The practical takeaway is simple: Wasm GC is reachability-based, not magic. In Kotlin/Wasm embeddings, the most reliable fix for GC heap out of memory is to shorten the lifetime of stores, instances, and exported handles, and to eliminate host-side retention of guest references wherever possible.

Leave a Reply

Your email address will not be published. Required fields are marked *