WeakRef, FinalizationRegistry and the Ephemeron Problem

A WeakRef is not a weak pointer. The target may be reachable only through the reference, so V8 uses ephemeron marking: the value is kept alive if and only if the key is.

TL;DR: A WeakRef looks like a weak pointer but cannot be one: if the target were kept alive only by the reference, the reference itself would keep it alive forever. V8 solves this with ephemerons - the rule “the value is reachable if and only if the key is” - implemented as a fixpoint iteration in MarkCompactCollector::MarkTransitiveClosureFixpoint with a ten-iteration cap, after which V8 switches to a linear-time algorithm. A FinalizationRegistry callback is scheduled by a GC that discovers the target has died, and runs later as a posted task, so the callback can never assume the target is still alive.

Every garbage collector has a way to say “this object may be collected if nothing else points at it”. JavaScript exposes that to the programmer as WeakRef, WeakMap and FinalizationRegistry.

The naive implementation is a weak pointer: a field that the collector treats as not-a-reference. It is short, obvious, and wrong - but not wrong in an interesting way. It is wrong in a way that has a name.

The problem: the reference keeps the target alive

Suppose w is a weak pointer to o, and o is only referenced by w:

text
   +-------+         +-------+
   |   w   | ------> |   o   |
   +-------+  weak   +-------+

If w is the only thing that mentions o, then either

  • w counts as a reference, in which case o is never collected and the weak pointer is a lie, or
  • w does not count, in which case o is collected, and w is a dangling pointer, and the next dereference is a use-after-free.

The standard fix is to say that a weak pointer is not a pointer at all: it is a handle to an object, and the handle keeps the object’s identity alive (the memory is not reused, the object is not reallocated) without keeping its contents alive. WeakRef in JavaScript is exactly this: deref() returns undefined once the target is gone, so the handle cannot be used to observe a collected object.

But WeakMap has a harder problem, and it is the one with the famous name.

The ephemeron problem

A WeakMap maps keys to values. The key is weak. The value is strong - but only conditionally strong: the value should survive if and only if the key survives.

That conditional is not expressible with ordinary reachability. Consider:

javascript
const key = {};
const value = { secret: 42 };
const map = new WeakMap();
map.set(key, value);

// `value` is only reachable through `map`, and `map` is only reachable
// through `key`.

Now drop the strong reference to key. The obvious rule (“the value is reachable if the map is reachable”) says value survives, because the map is alive. But the map is only alive because key is alive, and key is not. So value survives, and so does key (because map.set(key, value) is a strong reference from the map to the key? no - from the map to the key is weak)…

The clean way to say it: value is reachable if and only if key is reachable. That is a fixed point condition, and it is exactly what an ephemeron is. The name comes from the Greek: an ephemeron is a short-lived thing, and the pair (key, value) is such a thing - it exists only as long as the key does.

The rule is not equivalent to plain reachability, and the difference is observable. Consider a cycle:

text
   +-------+         +-------+
   |   a   | ------> |   b   |
   +-------+  ephem  +-------+
       ^                  |
       |                  v
       +--- ephem ------ +

Two objects, a and b, each ephemerally referencing the other, with no other roots. Under “value alive iff key alive”, neither is alive: a is not reachable, so b (as a’s value) is not reachable, so a (as b’s value) is not reachable. Under a naive “mark values of live ephemerons” rule, you would have to decide in which order to process them, and you would get different answers depending on the order. The fixed-point formulation has exactly one answer: the empty one.

This is why V8 does not implement WeakMap with “weak key, strong value”.

How V8 represents it

The WeakMap is an EphemeronHashTable, and the table’s own comment says what the GC does with it:

cpp
// src/objects/hash-table.h:479
// EphemeronHashTable is similar to ObjectHashTable but gets special treatment
// by the GC. The GC treats its entries as ephemerons: both key and value are
// weak references, however if the key is strongly reachable its corresponding
// value is also kept alive.
V8_OBJECT class V8_EXPORT_PRIVATE EphemeronHashTable
    : public ObjectHashTableBase<EphemeronHashTable, EphemeronHashTableShape> {
 public:
  static inline Handle<Map> GetMap(RootsTable& roots);

“Both key and value are weak references” - and then the value is resurrected if the key turns out to be strong. That is the ephemeron rule stated in one sentence, and it is why the table’s body descriptor is special: it does not iterate the values as ordinary strong fields.

cpp
// src/objects/objects-body-descriptors-inl.h:1937
class EphemeronHashTable::BodyDescriptor final : public BodyDescriptorBase {
 public:
  template <typename ObjectVisitor>
  static inline void IterateBody(Tagged<Map> map, Tagged<HeapObject> obj,
                                 int object_size, ObjectVisitor* v) {
    int entries_start = EphemeronHashTable::OffsetOfElementAt(
        EphemeronHashTable::kElementsStartIndex);
    IteratePointers(obj, OFFSET_OF_DATA_START(EphemeronHashTable),
                    entries_start, v);
    Tagged<EphemeronHashTable> table = UncheckedCast<EphemeronHashTable>(obj);
    for (InternalIndex i : table->IterateEntries()) {
      const int key_index = EphemeronHashTable::EntryToIndex(i);
      const int value_index = EphemeronHashTable::EntryToValueIndex(i);
      IterateEphemeron(obj, i.as_int(), OffsetOfElementAt(key_index),
                       OffsetOfElementAt(value_index), v);
    }
  }

The IterateEphemeron call is what tells the marking visitor “this slot is not a plain weak pointer - it is an ephemeron, and its value is conditional on its key”.

The fixpoint iteration

The marking phase in V8’s mark-compact collector is where ephemerons are resolved. MarkTransitiveClosureFixpoint runs the whole transitive closure repeatedly until nothing changes:

cpp
// src/heap/mark-compact.cc:2141
bool MarkCompactCollector::MarkTransitiveClosureFixpoint() {
  int iterations = 0;

  auto process_ephemerons_to_fixpoint = [this, &iterations]() {
    bool another_ephemeron_iteration_main_thread;

    do {
      if (iterations >= v8_flags.ephemeron_fixpoint_iterations) {

and the body of the loop is the interesting part:

cpp
// src/heap/mark-compact.cc:2164
      {
        Ephemeron ephemeron;

        // Drain current_ephemerons and push ephemerons where key and value are
        // still unreachable into next_ephemerons.
        while (local_weak_objects()->current_ephemerons_local.Pop(&ephemeron)) {
          if (ProcessEphemeron(ephemeron.key, ephemeron.value)) {
            another_ephemeron_iteration_main_thread = true;
          }
        }
      }

      {
        TRACE_GC(heap_->tracer(),
                 GCTracer::Scope::MC_MARK_WEAK_CLOSURE_EPHEMERON_MARKING);
        // In case any V8 object got marked, we need to reprocess ephemerons.

The loop alternates between two things: processing the pending ephemerons, and marking whatever became newly reachable. Each pass can mark new objects, which can make more ephemerons resolvable, which is why it needs more than one pass.

The individual ephemeron test is three lines of logic, and the middle case is the whole trick:

cpp
// src/heap/mark-compact.cc:2386
MarkCompactCollector::EphemeronResult
MarkCompactCollector::ApplyEphemeronSemantics(Tagged<HeapObject> key,
                                              Tagged<HeapObject> value) {
  // Objects in the shared heap are prohibited from being used as keys in
  // WeakMaps and WeakSets and therefore cannot be ephemeron keys, because that
  // would enable thread local -> shared heap edges.
  DCHECK(!HeapLayout::InWritableSharedSpace(key));
  // Usually values that should not be marked are not added to the ephemeron
  // worklist. However, minor collection during incremental marking may promote
  // strings from the younger generation into the shared heap. This
  // ShouldMarkObject call catches those cases.
  const auto target_worklist = MarkingHelper::ShouldMarkObject(heap_, value);
  if (!target_worklist) {
    // The value doesn't need to be marked in this GC, so no need to track
    // ephemeron further.
    return EphemeronResult::kResolved;
  }

  if (MarkingHelper::IsMarkedOrAlwaysLive(heap_, marking_state_, key)) {
    if (MarkingHelper::TryMarkAndPush(heap_, local_marking_worklists_.get(),
                                      marking_state_, target_worklist.value(),
                                      value)) {
      return EphemeronResult::kMarkedValue;
    } else {
      return EphemeronResult::kResolved;
    }
  } else {
    if (marking_state_->IsMarked(value)) {
      return EphemeronResult::kResolved;
    } else {
      return EphemeronResult::kUnresolved;
    }

Read it as a decision procedure:

  • If the key is marked (strongly reachable), the value is marked too. kMarkedValue.
  • If the key is not marked but the value is already marked (reachable by some other path), the ephemeron is kResolved - nothing to do, because the value is alive anyway. This is what makes the “already marked value” case terminate.
  • If neither is marked, the ephemeron is kUnresolved and goes back on the queue for the next iteration.

kUnresolved is the interesting one: it means “I cannot decide yet”. That is exactly the ephemeron problem in a nutshell - the answer depends on whether the key turns out to be reachable, which may depend on other ephemerons.

The ten-iteration cap and the linear fallback

An unbounded fixpoint iteration is a denial-of-service vector: a program can build a chain of WeakMaps where resolving one ephemeron marks one object, which resolves the next ephemeron, and so on. So V8 caps it:

cpp
// src/flags/flag-definitions.h:2660
DEFINE_INT(ephemeron_fixpoint_iterations, 10,
           "number of fixpoint iterations it takes to switch to linear "
           "ephemeron algorithm")

Ten iterations. If the fixpoint has not been reached by then, the collector switches algorithms entirely:

cpp
// src/heap/mark-compact.cc:2618
    // This is done as late as possible to keep locking durations short.
    CppHeap::From(heap_->cpp_heap())->EnterProcessGlobalAtomicPause();
    if (!MarkTransitiveClosureFixpoint()) {
      MarkTransitiveClosureLinear();
    }

The linear algorithm is the classic one from the literature: instead of repeatedly sweeping the ephemeron queue, keep a map from key to the list of values waiting on it, and when a key is marked, mark all of its values immediately.

cpp
// src/heap/mark-compact.cc:2258
  // Update visitor to directly add new ephemerons to key_to_values_.
  marking_visitor_->SetKeyToValues(&key_to_values_);

  Ephemeron ephemeron;
  while (local_weak_objects()->next_ephemerons_local.Pop(&ephemeron)) {
    if (ApplyEphemeronSemantics(ephemeron.key, ephemeron.value) ==
        EphemeronResult::kUnresolved) {
      auto it = key_to_values_.try_emplace(ephemeron.key).first;
      it->second.push_back(ephemeron.value);

and the payoff is in the marking worklist processing, where marking a key immediately marks everything waiting on it:

cpp
// src/heap/mark-compact.cc:2326
                  MarkingWorklistProcessingMode::kProcessRememberedEphemerons) {
      auto it = key_to_values_.find(object);
      if (it != key_to_values_.end()) {
        for (Tagged<HeapObject> value : it->second) {
          const auto target_worklist =
              MarkingHelper::ShouldMarkObject(heap_, value);
          if (target_worklist) {
            MarkObject(value, target_worklist.value());
          }
        }
        key_to_values_.erase(it);
      }
    }

So the linear algorithm is O(1) amortised per marked object, at the cost of a hash map. The fixpoint algorithm is O(ephemerons) per iteration but needs no map. V8 tries the cheap one first and falls back to the expensive one only for pathological heaps.

The scavenger’s problem: the ephemeron remembered set

The minor GC (the scavenger) has a much simpler job: it copies live young objects to the other semi-space. The complication is that a young key in an old WeakMap must not be strongified by the scavenger.

Normally the scavenger’s write barrier handles this: when an old object points at a young one, it records the slot in the OLD_TO_NEW remembered set, so the young object gets promoted rather than copied.

But a WeakMap in old space with a young key is not covered by that. The key slot is a weak reference, and the old-to-new remembered set would strongify it - the young key would be promoted and kept alive forever, which is exactly the bug weak maps are supposed to avoid.

V8’s solution is a dedicated remembered set for ephemerons:

cpp
// src/heap/ephemeron-remembered-set.h:15
namespace v8::internal {

// Stores ephemeron entries where the EphemeronHashTable is in old-space,
// and the key of the entry is in new-space. Such keys do not appear in the
// usual OLD_TO_NEW remembered set. The remembered set is used to avoid
// strongifying keys in such hash tables in young generation garbage
// collections.
// This remembered set is exclusively used for Minor GCs. Once we start
// incremental marking for a Full GC this remembered set is cleared. The markers
// and write barriers for the Full GC will record these slots in the regular
// OLD_TO_NEW remembered set then. In addition after a Full GC new space is
// empty because all live objects were promoted, so we have the guaranteee that

The write barrier for a weak slot in an ephemeron table is a different barrier from the ordinary one, and it routes to this set:

cpp
// src/heap/heap-write-barrier.cc:356
    Tagged<EphemeronHashTable> table, Address slot, Tagged<HeapObject> value) {
  SLOW_DCHECK_IMPLIES(
      kUninterestingPagesCanBeSkipped,
      MemoryChunk::FromHeapObject(table)->PointersFromHereAreInteresting());
  if (HeapLayout::InYoungGeneration(value)) {
    MutablePage* table_chunk =
        MutablePage::FromHeapObject(Isolate::Current(), table);
    table_chunk->heap()->ephemeron_remembered_set()->RecordEphemeronKeyWrite(
        table, slot);
  } else {
    DCHECK(MemoryChunk::FromHeapObject(value)->InWritableSharedSpace());
    DCHECK(!HeapLayout::InWritableSharedSpace(table));

The recording itself is cheap - it stores the table and the entry index:

cpp
// src/heap/ephemeron-remembered-set.cc:12
void EphemeronRememberedSet::RecordEphemeronKeyWrite(
    Tagged<EphemeronHashTable> table, Address slot) {
  DCHECK(HeapLayout::InYoungGeneration(HeapObjectSlot(slot).ToHeapObject()));
  int slot_index = EphemeronHashTable::SlotToIndex(table.address(), slot);
  InternalIndex entry = EphemeronHashTable::IndexToEntry(slot_index);
  base::MutexGuard guard(&insertion_mutex_);
  auto it = tables_.insert({table, IndicesSet()});
  it.first->second.insert(entry.as_int());

Note the IndicesSet - a flat_hash_set<int> of the entry indices in that table. The remembered set stores the table identity plus the indices, not the keys themselves, so it does not itself keep the young keys alive. It exists purely so the scavenger knows which weak slots to re-examine.

WeakRef and the “kept objects” list

A WeakRef is a different animal. Its target is a weak reference in the ordinary sense - the JSWeakRef object holds the target in a slot that the collector treats as a custom weak pointer:

cpp
// src/objects/objects-body-descriptors-inl.h:720
class JSWeakRef::BodyDescriptor final : public BodyDescriptorBase {
 public:
  template <typename ObjectVisitor>
  static inline void IterateBody(Tagged<Map> map, Tagged<HeapObject> obj,
                                 int object_size, ObjectVisitor* v) {
    IteratePointers(obj, offsetof(JSWeakRef, properties_or_hash_),
                    offsetof(JSWeakRef, target_), v);
    IterateCustomWeakPointer(obj, offsetof(JSWeakRef, target_), v);
    IterateJSObjectBodyImpl(
        map, obj, offsetof(JSWeakRef, target_) + kTaggedSize, object_size, v);
  }

But there is a subtlety. The spec says deref() must return the target if it is still alive, and the target must not be collected while the WeakRef is being used. If deref() returned a target that was about to be collected, the caller would hold a dangling reference.

V8’s solution is the kept objects list: deref() adds the target to a list of objects that are kept alive until the end of the current job.

torque
// src/builtins/weak-ref.tq:53
transitioning javascript builtin WeakRefDeref(
    js-implicit context: NativeContext, receiver: JSAny)(): JSAny {
  // 1. Let weakRef be the this value.
  // 2. Perform ? RequireInternalSlot(weakRef, [[WeakRefTarget]]).
  const weakRef = Cast<JSWeakRef>(receiver) otherwise
  ThrowTypeError(
      MessageTemplate::kIncompatibleMethodReceiver, 'WeakRef.prototype.deref',
      receiver);
  // 3. Let target be the value of weakRef.[[WeakRefTarget]].
  const target = weakRef.target;
  // 4. If target is not empty,
  //   a. Perform ! AddToKeptObjects(target).
  //   b. Return target.
  // 5. Return undefined.
  if (target != Undefined) {
    // JSWeakRefAddToKeptObjects might allocate and cause a GC, but it
    // won't clear `target` since we hold it here on the stack.
    runtime::JSWeakRefAddToKeptObjects(
        UnsafeCast<(JSReceiver | Symbol)>(target));

and the kept list is cleared at the end of the microtask checkpoint, which is the “job” in the spec’s language:

cpp
// src/execution/microtask-queue.cc:163
  RunMicrotasks(isolate);
  isolate->ClearKeptObjects();

The constructor does the same thing, and explains why the ordering of the assignment and the runtime call is what it is:

torque
// src/builtins/weak-ref.tq:39
  // 5. Set weakRef.[[WeakRefTarget]] to target.
  // We do step 5 and set the target before step 4 which calls
  // `runtime::JSWeakRefAddToKeptObjects`, as that method can trigger GC and
  // this reorder is otherwise not observable from JS. That way we can avoid
  // promoting the `weakRef` from the young generation to the old generation,
  // and don't need generational barriers for the reference from `weakRef` to
  // `weakTarget`.
  weakRef.target = weakTarget;
  // 4. Perfom ! AddToKeptObjects(target).
  runtime::JSWeakRefAddToKeptObjects(weakTarget);

That comment is a nice illustration of V8’s style: an observable-order spec is rearranged so that the GC can stay cheap, and the comment explains why the rearrangement is legal.

FinalizationRegistry: the cell, the list, and the task

A FinalizationRegistry is built out of two object types. The registry itself holds a cleanup callback and two doubly linked lists of cells:

torque
// src/objects/js-weak-refs.tq:11
extern class JSFinalizationRegistry extends JSObject {
  native_context: NativeContext;
  cleanup: Callable;
  active_cells: Undefined|WeakCell;
  cleared_cells: Undefined|WeakCell;
  key_map: Object;
  // For the linked list of FinalizationRegistries that need cleanup. This
  // link is weak.
  next_dirty: Undefined|JSFinalizationRegistry;
  flags: SmiTagged<FinalizationRegistryFlags>;
}

and the cell holds the target weakly, the held value strongly, and the unregister token weakly:

torque
// src/objects/js-weak-refs.tq:25
  finalization_registry: JSFinalizationRegistry;
  holdings: JSAny;
  target: Undefined|JSReceiver|Symbol;
  unregister_token: Undefined|JSReceiver|Symbol;

  // For storing doubly linked lists of WeakCells in JSFinalizationRegistry's
  // "active_cells" and "cleared_cells" lists.
  prev: Undefined|WeakCell;
  next: Undefined|WeakCell;

holdings is the value passed to register() and handed to the cleanup callback. target is the weak one. Both target and unregister_token are iterated as custom weak pointers by the body descriptor:

cpp
// src/objects/objects-body-descriptors-inl.h:699
class WeakCell::BodyDescriptor final : public BodyDescriptorBase {
 public:
  static constexpr int kTargetOffset = offsetof(WeakCell, target_);
  static constexpr int kUnregisterTokenOffset =
      offsetof(WeakCell, unregister_token_);
  static_assert(kUnregisterTokenOffset == kTargetOffset + kTaggedSize);

  template <typename ObjectVisitor>
  static inline void IterateBody(Tagged<Map> map, Tagged<HeapObject> obj,
                                 int object_size, ObjectVisitor* v) {
    IteratePointers(obj, sizeof(HeapObject), kTargetOffset, v);
    IterateCustomWeakPointer(obj, kTargetOffset, v);
    IterateCustomWeakPointer(obj, kUnregisterTokenOffset, v);
    IteratePointers(obj, kUnregisterTokenOffset + kTaggedSize, object_size, v);
  }

The interesting bit is the ordering of the iteration: holdings (which sits between finalization_registry and target) is iterated as a strong pointer, target and unregister_token are weak.

The moment of death

When a GC finds that a WeakCell’s target has died, it does three things: it enqueues the registry for cleanup, it nullifies the cell, and it moves the cell from the active_cells list to the cleared_cells list.

cpp
// src/heap/mark-compact.cc:4262
    Tagged<HeapObject> target = Cast<HeapObject>(weak_cell->target());
    if (MarkingHelper::IsUnmarkedAndNotAlwaysLive(
            heap_, non_atomic_marking_state_, target)) {
      DCHECK(Object::CanBeHeldWeakly(target));
      // The value of the WeakCell is dead.
      Tagged<JSFinalizationRegistry> finalization_registry =
          Cast<JSFinalizationRegistry>(weak_cell->finalization_registry());
      if (!finalization_registry->scheduled_for_cleanup()) {
        heap_->EnqueueDirtyJSFinalizationRegistry(finalization_registry,
                                                  gc_notify_updated_slot,
                                                  SKIP_WRITE_BARRIER_FOR_GC);

The Nullify call is where the target is actually cleared:

cpp
// src/objects/js-weak-refs-inl.h:306
void WeakCell::Nullify(Isolate* isolate,
                       GCNotifyUpdatedSlotCallback gc_notify_updated_slot) {
  // This method is only ever called during GC atomic pause. It is safe to use
  // `SKIP_WRITE_BARRIER_FOR_GC` since any needed barriers will be handled by
  // `gc_notify_updated_slot`.
  DCHECK(isolate->heap()->IsInGC());
  DCHECK(isolate->heap()->tracer()->IsInAtomicPause());

The dirty list

The registry is put on a dirty list - a linked list of registries that need cleanup - rather than having its callback invoked directly. That is necessary because you cannot run JavaScript during the GC’s atomic pause.

cpp
// src/heap/heap.cc:7117
void Heap::EnqueueDirtyJSFinalizationRegistry(
    Tagged<JSFinalizationRegistry> finalization_registry,
    std::function<void(Tagged<HeapObject> object, ObjectSlot slot,
                       Tagged<HeapObject> target)>
        gc_notify_updated_slot,
    WriteBarrierMode write_barrier_mode) {
  // Add a FinalizationRegistry to the tail of the dirty list.
  DCHECK(!HasDirtyJSFinalizationRegistries() ||
         IsJSFinalizationRegistry(dirty_js_finalization_registries_list()));
  DCHECK(IsUndefined(finalization_registry->next_dirty()));
  DCHECK(!finalization_registry->scheduled_for_cleanup());

and at the end of the GC, a task is posted:

cpp
// src/heap/mark-compact.cc:4307
  heap_->PostFinalizationRegistryCleanupTaskIfNeeded();

which does the actual posting:

cpp
// src/heap/heap.cc:7106
void Heap::PostFinalizationRegistryCleanupTaskIfNeeded() {
  // Only one cleanup task is posted at a time.
  if (!HasDirtyJSFinalizationRegistries() ||
      is_finalization_registry_cleanup_task_posted_) {
    return;
  }
  auto task = std::make_unique<FinalizationRegistryCleanupTask>(this);
  task_runner_->PostNonNestableTask(std::move(task));

“Only one cleanup task is posted at a time” - so the task itself must re-post if there is more work. And it must run the callback in the registry’s own context, because that is what the spec requires:

cpp
// src/heap/finalization-registry-cleanup-task.cc:57
  finalization_registry->set_scheduled_for_cleanup(false);

  // Since FinalizationRegistry cleanup callbacks are scheduled by V8, enter the
  // FinalizationRegistry's context.

Running the callbacks

The cleanup loop pops cleared cells and calls the callback with the held value:

cpp
// src/objects/js-weak-refs.cc:50
Maybe<bool> JSFinalizationRegistry::Cleanup(
    Isolate* isolate,
    DirectHandle<JSFinalizationRegistry> finalization_registry) {
  // 1. Assert: finalizationRegistry has [[Cells]] and [[CleanupCallback]]
  //    internal slots.
  // (By construction.)

  // 2. Let callback be finalizationRegistry.[[CleanupCallback]].
  DirectHandle<Object> callback(finalization_registry->cleanup(), isolate);

  // 3. While finalizationRegistry.[[Cells]] contains a Record cell such that
  //    cell.[[WeakRefTarget]] is empty, an implementation may perform the
  //    following steps:
  bool key_map_may_need_shrink = false;
  while (finalization_registry->NeedsCleanup()) {
    HandleScope scope(isolate);

    // a. Choose any such cell.
    // b. Remove cell from finalizationRegistry.[[Cells]].
    DirectHandle<WeakCell> weak_cell(finalization_registry->PopClearedCell(
                                         isolate, &key_map_may_need_shrink),
                                     isolate);

    // c. Perform ? HostCallJobCallback(callback, undefined,
    //    « cell.[[HeldValue]] »).
    DirectHandle<Object> args[] = {

Note the comment “an implementation may perform the following steps” - the spec explicitly leaves the timing of the callback unspecified. V8 runs it at the end of the next GC.

The rule that catches people out

The spec’s register throws if the held value is the same object as the target:

torque
// src/builtins/finalization-registry.tq:83
  // 4. If SameValue(target, heldValue), throw a TypeError exception.
  if (target == heldValue) {
    ThrowTypeError(
        MessageTemplate::kWeakRefsRegisterTargetAndHoldingsMustNotBeSame);
  }

Why? Because if the held value is the target, then the registry holds a strong reference to the target forever, and the target can never be collected - so the callback would never run, and the registry would leak both the target and the cell. The rule turns a silent leak into a loud TypeError.

The second rule is the deep one. A cleanup callback is invoked after the target has been determined to be garbage. The callback does not receive the target; it receives the held value. And the target, at the moment the callback runs, is in an unspecified state:

  • the object may have been collected already,
  • the object may still be alive, because the GC only decided it was unreachable, and V8 gives you no guarantee about when the memory is actually reclaimed,
  • and if you resurrected the target from somewhere else, the callback will still fire.

So the callback cannot assume the target is gone, and it cannot assume the target is alive either. The only thing it can rely on is the held value it was given.

The last piece of the puzzle is that the callback runs in a task, not synchronously in the GC. That means:

  • the callback runs at an unpredictable time (after the GC, at the next microtask checkpoint),
  • the callback can allocate, run JavaScript, and even trigger another GC,
  • and if the callback throws, the exception is reported but the cleanup continues with the next cell.

V8 handles the exception case explicitly - the loop returns early and the registry is re-queued:

cpp
// src/heap/finalization-registry-cleanup-task.cc:81
  // verbose TryCatch.
  //
  // Cleanup is interrupted if there is an exception. The HTML spec calls for a
  // microtask checkpoint after each cleanup task, so the task should return
  // after an exception so the host can perform a microtask checkpoint. In case
  // of exception, check if the FinalizationRegistry still needs cleanup

Trade-offs

What it buys. Weak maps and weak references that actually mean “weak” - the key of a WeakMap does not keep the map alive, and the value does not keep the key alive. FinalizationRegistry gives you a callback at a well-defined-ish point in the object’s lifetime, without requiring a language-level destructor.

What it costs.

  • The ephemeron marking is a second algorithm. Every mark-compact GC must run the fixpoint iteration, and pathological heaps need the linear fallback with its hash map. The ten-iteration cap means the result is not always the “true” fixpoint - it is the fixpoint of a bounded number of rounds, which is a deliberate approximation for DoS resistance.
  • The scavenger needs a second remembered set. The ordinary OLD_TO_NEW set cannot express “this weak slot points at a young object”, so EphemeronRememberedSet exists alongside it, with its own mutex.
  • Finalization callbacks are unpredictable. They run in a posted task, at the mercy of the event loop, and they can be delayed arbitrarily.
  • The kept-objects list adds a GC root. Every deref() adds to it, which keeps the target alive until the end of the microtask checkpoint.
  • The dirty list is a weak linked list. Registries can be collected while waiting for cleanup, which is why next_dirty is weak and why the list is rescanned.

The bottom line

The mental model to keep: a weak reference is not a weak pointer, it is a handle plus a conditional liveness rule. The handle part is easy - a custom weak slot in the object. The conditional part is the ephemeron, and it is the only genuinely hard part of the whole feature.

The second mental model: the GC does not run your callback. The GC decides the target is dead, records that fact, and moves on. The callback is run later, in a task, in the registry’s context, with only the held value. Everything you might want to assert about the target at that point is either false or unspecified.

The third: the ephemeron answer is a fixed point, and V8 computes it with a bounded number of rounds. If you build a WeakMap chain deep enough that ten rounds do not resolve it, V8 switches to the linear algorithm rather than looping forever. That is a correctness-preserving engineering decision, but it means the ephemeron semantics are “the fixpoint, computed efficiently”, not literally “the mathematical fixpoint”.

The thing people get wrong

The belief that new WeakRef(obj) “makes obj collectable soon”. It does not make it collectable at all if anything else still references it, and WeakRef itself is not a root. The common bug is holding the target in a closure or a module-level variable, which keeps it alive regardless of how many WeakRefs point at it.

The second belief is that FinalizationRegistry is a destructor. It is not a destructor: it is a notification that may arrive, at a time you do not control, and possibly not at all if the process exits first. Code that depends on the callback running is code that can hang.

The third is that the cleanup callback receives the target. It does not - it receives the held value, precisely because the target may already be gone. That is why register(target, heldValue) throws when the two are the same object: the registry must not be able to keep its own target alive.

V8InternalsGcWeakrefsEphemerons