Using WeakSet to Tag Objects Without Leaks

A “seen” Set in a deep-clone utility, a “processed” set in an event pipeline or a “validated” set in a form library keeps every object it ever touched alive, and memory grows with every operation. This guide from Reference Counting vs Tracing GC Algorithms, in JavaScript Memory Fundamentals & Runtime Mechanics, shows when a WeakSet is the right tool for tagging objects, how it avoids retention, and where its limits are.

Symptom Root Cause Immediate Action Measurable Impact
Long-lived Set of processed objects grows forever Set membership is a strong reference Replace with WeakSet when you only need has() Tagged objects collectable when otherwise unreferenced
Adding a _processed flag breaks frozen or third-party objects Mutating objects you do not own Tag externally with a WeakSet No mutation, no leak
Cycle detection in a recursive walk leaks between calls “Seen” set stored at module level Create a fresh set per walk, or a WeakSet if it must persist No growth across calls
Need to count or list tagged objects WeakSet is not iterable and has no size Use a Set with explicit removal, or a counter Correct tool for the requirement
Branded-object checks via symbols leak through proxies or copies Symbol flags travel with spread copies Use a WeakSet of genuine instances Brand checks that cannot be forged by copying

Root Cause: Membership Should Not Mean Ownership

Tagging is a common need: remember that an object has been validated, processed, serialised, registered or created by a trusted factory. The obvious implementations each have a flaw. Adding a property such as obj._processed = true mutates objects you may not own, fails on frozen objects, changes their hidden class (see how hidden classes and inline caches affect memory) and leaks into serialisation. Keeping a Set of tagged objects avoids mutation, but a Set holds strong references: as long as the set is reachable, every member is too. A long-lived Set of processed events, rendered nodes or validated records therefore retains all of them for the life of the program.

A WeakSet stores membership without keeping members alive. If the only thing referencing an object is the WeakSet, the object can be garbage collected, and its membership simply disappears. Under the hood, the engine treats weak collections as ephemeron tables: an entry is traced only if its key is reachable from elsewhere. That makes WeakSet leak-free by construction for tagging, and it survives cycles: an object that references the WeakSet, or other members, is still collectable once nothing outside points to it.

The price of that guarantee is capability. A WeakSet can only hold objects (and, in newer engines, non-registered symbols), cannot be iterated, and has no size, because its contents depend on garbage-collection timing and exposing them would make program behaviour non-deterministic. If you need to enumerate or count tagged objects, you need a strong collection with explicit removal — and then you have to manage lifetime yourself, as with any module-level cache. The broader comparison of weak structures is in WeakMap vs WeakRef vs FinalizationRegistry; WeakSet is simply the WeakMap special case where the value is “present”.

Strong Set versus WeakSet after the app drops objects Left: a module-level Set named processed holds events one, two and three. The application has dropped events one and two, but the Set's strong references keep all three alive. Right: a WeakSet holds the same three events; after the application drops events one and two, they are collected and their membership disappears, leaving only event three, which the application still uses. const processed = new Set() event #1 — app dropped it, still alive event #2 — app dropped it, still alive event #3 — in use grows with every event ever processed const processed = new WeakSet() event #1 — collected, entry gone event #2 — collected, entry gone event #3 — tagged, in use size tracks live objects only

Step-by-Step Fix

  1. Find long-lived tagging sets. Search for module-level or long-lived new Set() instances used only with add and has. Verification: you have a list of sets that never call delete, size or iterate.
  2. Confirm they retain objects. Take a heap snapshot after a long session and check the retained size of each set. Verification: at least one set retains objects the application no longer uses.
  3. Swap to WeakSet where only membership is needed. Replace new Set() with new WeakSet(); the add/has/delete calls stay the same. Verification: tests still pass; no code needs size or iteration.
  4. Scope short-lived “seen” sets per operation. For cycle detection in a single traversal, create the set inside the function call rather than at module level; a plain Set is fine because it dies with the call. Verification: memory does not grow across calls.
  5. Use a strong collection where enumeration is required, with explicit removal. If you must list or count tagged objects, keep a Set but remove entries on teardown. Verification: the set’s size tracks live objects in long sessions.
  6. Re-measure the long session. Repeat step 2. Verification: replaced sets no longer appear among the largest retainers.
Choosing the tagging structure If the tag only needs has and add and must outlive one operation, use a WeakSet. If it only lives for one traversal, use a Set created inside the call. If you need size or iteration, use a strong Set with explicit removal. Use a property flag only on objects you own and create yourself. WeakSet has/add only long-lived tag objects you do not own Set per call one traversal cycle detection dies with the call Set + delete need size or iteration remove on teardown Property flag only on objects you create declared in the constructor

Command and Code Reference

Use case: an event pipeline that must not process the same event object twice.

// Leaky: module-level Set keeps every event ever seen
const seen = new Set();
export function handle(event) {
  if (seen.has(event)) return;         // duplicate delivery guard
  seen.add(event);
  process(event);
}

// Fixed: WeakSet membership disappears when the event is otherwise unreferenced
const seenWeak = new WeakSet();
export function handleWeak(event) {
  if (seenWeak.has(event)) return;
  seenWeak.add(event);
  process(event);
}

Use case: a brand check that cannot be forged by copying. Only instances created by the factory are in the set; spread copies or look-alike objects are not.

const genuine = new WeakSet();

export function createToken(claims) {
  const token = Object.freeze({ ...claims, issuedAt: Date.now() });
  genuine.add(token);                   // tag without mutating a frozen object
  return token;
}

export function isGenuine(token) {
  return genuine.has(token);            // { ...token } copies fail this check
}

Use case: cycle detection inside one traversal. A per-call Set is the right choice here; it cannot leak because it dies with the call.

export function deepFreeze(root) {
  const visited = new Set();            // local: released when the call returns
  (function walk(o) {
    if (o === null || typeof o !== 'object' || visited.has(o)) return;
    visited.add(o);
    Object.freeze(o);
    for (const v of Object.values(o)) walk(v);
  })(root);
  return root;
}

Verification and Regression Prevention

Verify with the long-session scenario that exposed the leak: after switching to WeakSet, the tagging structure should not appear among the largest retainers, and the tagged objects should be collected when the application drops them. A collectability test using WeakRef — tag an object, drop all references, collect, assert deref() is undefined — proves the tag no longer retains, as described in writing memory leak tests with Vitest and --expose-gc.

For prevention, add a lint or review rule for long-lived Sets: if the code only uses add and has, it should be a WeakSet; if it needs size or iteration, it must have a documented removal path. Keep “seen” sets for traversals local to the traversal function.

A collectability test for tagged objects Tag an object with the WeakSet, drop every strong reference to it, run a forced collection in a test process started with --expose-gc, and assert that a WeakRef to the object now derefs to undefined. If it is still defined, something besides the tag retains it. Tag tagged.add(obj) Drop references only a WeakRef remains Collect await gc() in test Assert ref.deref() === undefined

Edge Cases and Gotchas

Primitives cannot be members

WeakSet accepts only objects (and non-registered symbols in newer engines). Tagging strings or numbers requires a normal Set, with its own lifetime management — or tagging the object that owns them instead.

The WeakSet itself must be reachable to matter

Membership exists only while the WeakSet is reachable. Creating a new WeakSet per request and discarding it loses all tags — that is fine for per-request scope, but not for a global “already processed” guard.

Collection timing is not observable

Because there is no size or iteration, you cannot observe when members disappear, and you should not try. If behaviour depends on whether an object was collected, redesign; weak collections are for memory safety, not program logic.

Proxies are different objects

A Proxy wrapping a tagged object is a different identity, so has(proxy) returns false. Tag the object your code actually passes around, or unwrap before checking.

Frequently Asked Questions

When should I use WeakSet instead of Set?

When you only need to add objects and check membership, and the set outlives the objects’ normal use. A WeakSet does not keep its members alive, so it cannot leak them. If you need to count or iterate members, you need a Set with explicit removal.

Why can’t I iterate a WeakSet?

Its contents depend on when the garbage collector runs. Allowing iteration or size would make program behaviour depend on GC timing, so the language deliberately omits them. Only add, has and delete are available.

Is WeakSet slower than Set?

Lookups are comparable for practical purposes. Weak collections add some work for the garbage collector, which must process them specially, but for typical tagging workloads the difference is negligible compared with the memory saved.

What about WeakMap for tags that carry data?

If the tag needs a value — when an object was validated, which user processed it, a cached derived result — use a WeakMap keyed by the object instead of a WeakSet. It has the same leak-free semantics for keys, with the caveat that a value which references its own key keeps nothing alive by itself, because weak collections are traced as ephemerons.

Can a WeakSet create a memory leak?

Not through its members. It can still be part of a leak if the objects it tags are retained by something else — the WeakSet just will not be the reason. A heap snapshot’s retainer path will show the real owner.