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”.
Step-by-Step Fix
- Find long-lived tagging sets. Search for module-level or long-lived
new Set()instances used only withaddandhas. Verification: you have a list of sets that never calldelete,sizeor iterate. - 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.
- Swap to
WeakSetwhere only membership is needed. Replacenew Set()withnew WeakSet(); theadd/has/deletecalls stay the same. Verification: tests still pass; no code needssizeor iteration. - 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
Setis fine because it dies with the call. Verification: memory does not grow across calls. - Use a strong collection where enumeration is required, with explicit removal. If you must list or count tagged objects, keep a
Setbut remove entries on teardown. Verification: the set’s size tracks live objects in long sessions. - Re-measure the long session. Repeat step 2. Verification: replaced sets no longer appear among the largest retainers.
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.
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.
Related
- Reference Counting vs Tracing GC Algorithms — the parent topic
- Do Circular References Leak Memory in JavaScript? — why tracing GC handles cycles that tagging structures create
- FinalizationRegistry Callbacks That Never Run — the weak API that should not carry critical cleanup
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview