Skip to content

Canonical definitions for weak references and finalizers #752

Open

Description

@simolus3

As suggested in #525 (comment), I'm opening this issue to propose weak references and finalizers as features for the Component Model.

For context, these features would help components built in garbage collected languages (like Dart and Kotlin) reference resources from structs and arrays, registering custom callbacks to dispose them when they're no longer referenced. Even outside of the component model, weak references are commonly used on those languages to implement e.g. caches, and not having them on WASI targets prevents a full implementation of these languages.

Shouldn't this be part of core WebAssembly?

One could argue that these constructs, which are available in most popular languages using garbage collection, should be part of the core WebAssembly spec instead of the component model. However, it doesn't look like there's a current proposal to advance support for weak references or finalizers in core WebAssembly. This is also understandable, as an embedder with support for those can easily provide them as host functions without that causing a significant slowdown (that's what everyone does on the web). These custom imports should be part of the component model to share them across embedders.

Another issue is that finalizers require running callbacks, so then there's a question of when to run them (and on which thread). For example, Java uses a blocking queue that a cleaner invokes in a fresh thread, while JavaScript and Dart expect finalizers to run as event loop iterations. Encoding these expectations in WebAssembly is hard, but the component model already exposes the right primitives through waitables and its different concurrency options. So I think the Component Model is a perfect home for finalizers.

Arguably, weak references could be part of core WebAssembly. But having them in the Component Model unblocks languages from targeting it soon-ish, as opposed to the multiple years it would take to standardize this in core WebAssembly. The GC proposal includes weak references as a post-MVP proposal, but also notes that finalizers are difficult to map to WebAssembly because different languages have different expectations on how they work (something mostly solved by the component model).

From @justinfagnani in #525 (comment):

Shouldn't weakrefs and finalization registries be in Wasm GC proper, not the component API? This would be one more thing that a toolchain has to compile differently for core modules on JS vs components.

Weak references and finalizers are already based on function imports on the web, and changing imports to component imports doesn't affect a compiler much.

And a follow-up from @spotandjake in #525 (comment)

I had a similar concern, if finalization registries and or weakrefs are added to the component model, it may duplicate effort or cause duplicate apis if similar wasm proposals are made as extensions to wasm gc.

I believe there are other canon builtins that will later map onto core WebAssembly features (like e.g. stack switching). So once we have them in core WebAssembly, these canon definitions could be implemented on top of those definitions.

Weak references

I propose we add two new canonical definitions:

(canon reference.create $type (core func $create))
(canon reference.get (core func $get))

validation specifies:

  • $create is given type (func (param (ref any)) (result (ref extern)))
  • $get is given type (func (param (ref extern)) (result (ref null any)))
  • initially, $type must be weak. We can later support other references, like Java's soft and phantom references.

$create creates a weak reference and $get resolves it if it's still strongly reachable. A CM embedder is allowed to implement weak references with strong references, components can't assume that weak references are actually supported for their operation. For example, an embedder might choose to disable garbage collection entirely for short-lived components, and then ignore weak references.

Finalizers

Finalizers allow running callbacks asynchronously after an object has been garbage collected. I propose these canonical definitions for them:

(canon refqueue.create (core func $create))
(canon refqueue.attach (core func $attach))
(canon refqueue.detach (core func $detach))
(canon refqueue.poll (core func $poll))
(canon refqueue.drop (core func $drop))

validation specifies:

  • $create is given the type (func (result i32)), $drop is given the type (func (param i32)).
  • $attach is given the type (func (param i32) (param (ref any)) (param anyref) (param anyref))
  • $detach is given the type (func (param i32) (param (ref any)))
  • $poll is given the type (func (param i32) (param (ref $x)) (result i32)), where $x matches (array (mut anyref)) and needs to be included as a canon option (Pre-Proposal: Wasm GC Support in the Canonical ABI #525). Maybe we can have a non-batching poll instead to avoid having to specify a type.

refqueue.create returns an instance of a class ReferenceQueue(Waitable), meaning that reference queues can be added to waitable sets. refqueue.attach tracks a non-nullable object (the second parameter). I think tracking an i31ref should probably be a trap, but I need to figure that one out still. Once the parameter becomes unreachable, the third parameter is added to the reference queue and we call Waitable.set_pending_event to dispatch that as a notification. The fourth parameter is an optional detach token. When non-null, calling refqueue.detach can be called with that object undo an earlier attach call.

The second and fourth parameters to attach don't count towards those objects being accessible to the program. Again, this should be defined in a way that allowed embedders to never actually add anything to the reference queue. If an object is registered on multiple reference queues and becomes unreachable, it may be enqueued on any number (including none) of them. Once a held value has been enqueued, all reference.getcalls on it must return null. I don't think we need to be stricter than that (e.g. to only set weak references to null when no user code runs like in JS).

Pseudo-JS implementation of reference queues:

class ReferenceQueue extends Waitable {
  #pending = [];
  #registry = new FinalizationRegistry((v) => {
    this.#pending.push(v);
    if (this.#pending.length === 1) this.set_pending_event(/* todo: event code for refqueues */);
  });

  attach(object, param, detachToken) {
    this.#registry.register(target, held, token ?? undefined);
  }
  detach(token) {
    this.#registry.unregister(token);
  }
  poll(target) {
    const popped = this.#pending.splice(0, target.length);
    popped.forEach((v, i) => { target[i] = v; });
    return popped.length;
  }
}

I want to experiment with these builtins in jco and a Dart implementation of components, and I'll share results of that here. But feedback and discussion items on these proposals would be very welcome, thanks!

Activity

  1. spotandjake commented on Oct 22, 2026

    @spotandjake

    I think this is a really interesting proposal, and I appreciate the work that's gone into exploring it. I'm not entirely convinced this is the right direction yet, though, and I wanted to share an alternative idea I've been thinking about.

    I think the argument that these features could initially be implemented in the Component Model and later built on top of core Wasm support is fair. However, I'm not entirely convinced that the duplication is entirely justified. If weak references and finalization registries are eventually implemented in core Wasm, much of the motivation for exposing them as Component Model features seems to disappear.

    I have an alternative idea, although I haven't worked through all the details of how it would fit into the Component Model, so I may be missing some important considerations.

    Finalization registries seem like a useful way to support resource cleanup when GC-managed objects become unreachable. However, they also solve a much broader set of problems, and I'm wondering whether that scope is larger than what we need to address here.

    My alternative would still use finalization, but in a more narrowly scoped way. The idea would be to provide a canonical function something along the lines of ref.drop_link that associates a GC object's lifetime with the release of an owned resource handle.

    I haven't worked through the exact semantics yet. The idea is that calling ref.drop_link would associate the GC reference with the owned resource handle. When the referenced object becomes unreachable, the engine would arrange for the resource handle to be dropped using the existing canonical resource-drop mechanism.

    The exact mechanism would depend on how ownership is represented, but I imagine this working differently depending on how values cross the component boundary.

    • Linear memory to linear memory: ref.drop_link wouldn't be needed. Consumers would manage resource lifetimes through explicit drop calls, as they do today.

    • GC to linear memory: This use case is already supported today. The host can keep GC references in a reference pool and explicitly remove them when the linear-memory consumer calls free. This allows the GC object to remain reachable for as long as the consumer needs it, without requiring GC-driven cleanup across the boundary.

    • Linear memory to GC: This is where I think ref.drop_link would be useful. A GC host could associate its representation of a resource with the corresponding owned resource handle. When that GC object becomes unreachable, the engine could arrange for the handle to be dropped, without requiring the host language to implement its own finalization registry for this purpose.

    • GC to GC: This could combine the two approaches, depending on which side owns the resource and which side is responsible for keeping the relevant object alive.

    This still relies on something conceptually similar to a finalization registry internally, but it would expose a much smaller API surface tailored to resource lifetime management at component boundaries, rather than providing a general-purpose finalization mechanism.

    Additionally, if in the future GC objects have canonical representations that can be associated across component boundaries, it might be possible to provide a complementary operation, perhaps ref.link_ref, for linking the lifetimes of GC objects on either side of the boundary. I haven't explored this idea enough to say whether that would be practical, but it could be an interesting extension.

    I'd be interested to hear whether this narrower approach is feasible, or whether there are reasons why exposing general-purpose weak references and finalization registries at the Component Model level would be preferable.

  2. justinfagnani commented on Oct 22, 2026

    @justinfagnani

    WeakRefs, ephemerons, and finalizers are definitely needed in Wasm GC, but I don't see why they should be part of the component model and more part of Wasm GC proper.

    Weak references and finalizers are already based on function imports on the web, and changing imports to component imports doesn't affect a compiler much.

    What I mean is that a compiler has to fork its emit based on wether its targeting JS or the component model and import either JS helpers or use component builtins. To-Wasm compilers already have to do too much forking based on the target, so I would like to not add more to that pile.

    Plus, that's only JS and the component model. Core modules not targeting a specific environment would still have no WeakRefs available.

    it doesn't look like there's a current proposal to advance support for weak references or finalizers in core WebAssembly.

    Yes, there has to be a proposal, but you're making a proposal for the component model, why not make one for WebAssembly? I don't see why it should inherently take less time, except for possibly a smaller group to get consensus on or a lower bar for inclusion, but assuming or relying on those doesn't seem ideal path towards standardization to me.

    The scheduling issues are still present, no matter where the feature lives. If the finalizer scheduling doesn't work for Java here, then what? Why is that acceptable here, but not in core WebAssembly?

    I would have to dig into this space a lot more to provide a more informed answer to scheduling in core, but there are multiple possible answers, some of which at least have been discussed before:

    • Polling: at the guest's choosing, check if there are any to-be-finalized references in the queue. Guest languages can do this in their event loop if they have one, at async suspension points, in a separate thread, synchronously at function calls and loop back-edges, etc. Then they can schedule the finalizers according to their behavior.
    • Blocking or waiting: Multi-threaded modules can start a finalization thread and call a blocking instruction to check the queue, or a wait instruction.
    • Stack switching: combined with polling, the main stack can yield to a cleanup stack, and the resume
    • Interrupts: As in Pre-proposal: Interrupt handling design#1446

    With a fast finalizers_pending check, it seems like the guest can schedule the finalizers however they want.

  3. lukewagner commented on Oct 23, 2026

    @lukewagner
    Member

    I appreciate the argument for not wanting to duplicate core wasm functionality. That being said, because the CM is a "layer up" from core wasm, it's not terrible if the CM has built-ins that are later redefined in terms of core wasm instructions, and indeed this is what the Threads SG proposed the CM do a while back with thread.spawn_ref et al. The reason was that while thread-spawning should get added to core wasm in the future, for practical resourcing/prioritization purposes, it won't get added any time soon and thus a CM built-in is a good stopgap. Similarly, CM 0.3 concurrency is specified in terms of future hypothetical stack-switching instructions. I think the same argument might apply here: I haven't seen any proposals or heard of any imminent plans to propose finalization/weak-ref primitives, which means these are in all likelihood years away and so defining CM built-ins that can be polyfilled via FinalizationRegistery/WeakRef today seems like a practical stopgap.

  4. fitzgen commented on Oct 23, 2026

    @fitzgen
    Collaborator

    I don't think we need callbacks for this. Could instead probably represent this with streams instead, which removes one more thing to standardize from the critical path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions