Back to Knowledge Hub
Fingerprint

WeakMap and WeakSet: What They Hold and What They Drop

One probe on fixed shapes: which keys a WeakMap accepts, why nothing can be counted, what a strong Map keeps alive, and what a weak collection cannot survive.

BotBrowser Team

Documentation

Want the structured docs for Fingerprint?

This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.

BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping a bounded record of what a page stored and when it stored it, but it cannot enumerate the contents of a weak collection, cannot read an entry whose key the page no longer holds, and cannot turn a dropped entry back into a value. This review runs one probe over eleven measured lines and records which key types a WeakMap accepts, what its own methods do with a primitive key, whether a strong Map keeps the same key alive, what WeakRef and FinalizationRegistry add, and what a weak collection does when it is serialized.

TL;DR

  • A WeakMap accepts objects, functions, arrays and non-registered symbols, and rejects null, strings and numbers with a TypeError, so the probe prints accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError.
  • A registered Symbol.for() key is rejected, so the symbol rule covers Symbol() and well-known symbols but not the registry.
  • Neither WeakMap nor WeakSet has size, keys, values, entries or forEach, so the probe finds none for both.
  • A strong Map and a WeakMap can hold the same key at once, and dropping the outside reference leaves the strong entry in place with size=1.
  • delete then get returns undefined, and a later set on the same key works, so a weak entry is replaceable rather than consumed.
  • set throws on a primitive key while get, has and delete return a miss, which is why a read path can stay silent about a bad key.
  • WeakRef.deref() returns the target while it is still reachable, and after a second requested collection it returns undefined, which is a collection signal that needs no registry.
  • FinalizationRegistry adds a callback with a held value, and a PerformanceObserver on entryTypes: ['gc'] also reports each collection, so neither one is the only observer.
  • A WeakMap serializes to {} through JSON.stringify and throws a DOMException through structuredClone.

Contents

A WeakMap panel shows that object, function, array and symbol keys are accepted while null, string and number keys throw TypeError, a second panel shows the missing size and enumeration methods, a third shows a strong Map keeping an entry after the outside reference is dropped, and a fourth shows JSON.stringify producing an empty object while structuredClone throws a DOMException

What a weak collection actually holds

A WeakMap holds an association between a key and a value without holding the key alive. The value is reachable only while the key is reachable from somewhere else, and once nothing else points at the key, the whole entry becomes collectable. The probe checks the two halves of that sentence separately, because they fail in different ways.

node=v26.9.0
weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError
no-enumeration -> weakmap=none weakset=none
same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1
delete-then-read -> undefined
reset-after-delete -> second
weakref-deref-before -> true
primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError
missing-object-key -> undefined
weakset-identity -> frozen=true copy=false
serialization -> json=ok:{} structuredClone=throws:DOMException

The first line is about the key, the second is about the collection, and the rest are about what happens around them. Reading the output in that order is the fastest way to keep the three separate, because the properties people expect from a Map are spread across all three answers and none of them hold in the way a Map user expects.

The practical consequence is that a WeakMap is not a cache with a size limit, and the neighbouring question of which values survive a copy is answered by structuredClone versus JSON, which this collection cannot do at all. It is a place to attach data to an object you already hold, with the guarantee that the attachment disappears when the object does. A page that stores per-element state in a WeakMap instead of a property on the element gets no cleanup code, and a page that stores per-request state in a WeakMap keyed by a request object gets the same. What it does not get is any way to ask how much is stored, which is why the next two sections are about what you can and cannot observe.

Which keys a WeakMap accepts

The key must be something the runtime can hold weakly. The probe tries seven key types and records which ones survive set:

weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError

Objects, functions and arrays are the obvious cases. Symbols are the interesting one, because the language changed: a non-registered symbol became a valid weak key so that a symbol-keyed record could be collected the same way an object-keyed one is. The probe confirms it on this runtime, and a reader on an older engine should treat the symbol row as version dependent rather than universal. The exception is the registry: a separate probe sets Symbol(), Symbol.for() and Symbol.iterator as keys and prints Symbol()=accepted, Symbol.for()=TypeError and Symbol.iterator=accepted, so a registered symbol is not a weak key even though an unregistered one is.

null is rejected because it is not an object, and strings and numbers are rejected for the same reason: they are primitives, and a primitive has no identity that a collector could observe. A caller that keys a WeakMap by a string id is therefore building something that cannot work, and the failure arrives at set time rather than later, which is the friendlier half of the problem.

The unfriendly half is that the read methods do not fail the same way. The probe calls each method with the string text:

primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError

Only set throws. get returns undefined, has returns false and delete returns false, all of which are the same answers a valid but absent key produces. A read path that accepts an id from a caller therefore cannot tell "this key is invalid" from "this key has no entry", and a write path is the only place the mistake becomes visible. A helper that normalizes every id into an object before touching the map closes that gap, and a helper that does not leaves a silent miss in production.

A frozen object is still a valid key, and membership is decided by identity rather than by shape. The probe registers one frozen object and then asks about a different object with the same contents:

weakset-identity -> frozen=true copy=false

The frozen original is a member and the copy is not, even though the two serialize identically. That is the same rule Map and Set follow, and it is worth stating because a caller who keys by "the object with id 1" rather than "this object" gets a miss that looks like a bug in the collection.

Why nothing can be counted or listed

A weak collection cannot report its own contents, because reporting them would be the very thing that keeps them alive. The probe asks each collection whether it has the methods a Map or Set user would reach for:

no-enumeration -> weakmap=none weakset=none

Neither collection has size, keys, values, entries or forEach. That is not a missing feature; it is the definition. A size property would have to count the entries the collector is free to drop, so the number would change without any code running, and a keys() iterator would hand the caller a strong reference to every key it produced.

The practical cost is that a page cannot report "this weak map holds twelve entries" in a diagnostic. What it can report is the count of the objects it still holds strongly, which is a different number and often the more useful one: if the page keeps a list of the elements it attached data to, that list is the strong side, and the weak side can only be inferred from it.

The cost also shows up in testing. A test that wants to prove an entry was collected cannot assert on the collection, because there is nothing to assert on. It has to assert on the observable half, which is WeakRef.deref() returning undefined, and that requires the collector to have run. The next section covers the pair of APIs that exist for exactly this purpose, and the section after it covers the one thing a test can check without a collection at all.

There is a third cost that shows up in an audit rather than in code. A reviewer who wants to know whether a page leaks has to reason about the strong side alone, because the weak side cannot be enumerated. The question "how many entries does this map hold" has no answer, and the question that does have an answer is "how many objects does the page still hold that could be keys". A page that keeps a registry of open panels, a list of pending requests, or an array of mounted elements is holding exactly that many potential keys, and the weak entries attached to them live as long as those lists do. Reading a memory profile is therefore a matter of finding the strong lists, not of finding the weak maps.

What a strong reference does to the same key

A weak entry disappears only when nothing else holds the key. The probe puts the same key in a strong Map and a WeakMap at once, then drops the outside reference:

same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1

The weak lookup still answers while the variable holds the key, which is the expected half. The second line is the one that matters: after the variable is reassigned, the strong map still reports one entry. The weak map did not forget the value either, because the strong map still holds the key, so reading the weak map through that key still answers value. The strong side is what keeps the pair alive, and the weak side followed it.

That interaction is the most common way a page accidentally defeats its own weak collection. A module that stores metadata in a WeakMap and also pushes every key into an array for a debug panel has made every entry permanently reachable, and the debug panel is the reason nothing is ever collected. The array does not have to be used; it only has to exist and be reachable from a live scope.

The reverse mistake is assuming that delete is how an entry goes away. The probe deletes an entry and then reuses the same key:

delete-then-read -> undefined
reset-after-delete -> second

delete removes the association and returns a boolean, and the key itself is untouched, so a later set on the same key works normally. A caller who expects a deleted key to become unusable, in the way a one-shot token might, will be surprised: the collection forgets the value, not the key.

The third mistake is expecting a weak collection to bound memory. It bounds nothing, because it has no size to bound. A page that leaks memory through a WeakMap is leaking through the values, not the keys: a value that points back at its own key, or at a large buffer, keeps that buffer alive exactly as long as the key lives. The probe cannot see this, because the probe holds no such cycle, and it is the reason a memory review has to look at the values as carefully as at the keys.

A fourth mistake is subtler and shows up in single-page applications. A page that keys a WeakMap by a route object creates a new key on every navigation, so the map accumulates one entry per navigation that has no other reference, and the collector is free to drop all of them. That is exactly the intended behaviour, and it is also why a counter built on the same map never decreases: the counter is the strong side. A page that wants to measure navigation churn has to count navigations, not entries.

The last structural point is that a weak key can be a proxy, a cross-realm object or a frozen record, and all three behave the same way for retention. The collector tracks reachability, not shape, so the only thing a page can do to change the outcome is to add or remove a strong reference somewhere else. That makes the weak collection a follower: its behaviour is entirely determined by code that never mentions it.

What WeakRef and FinalizationRegistry add

WeakRef and FinalizationRegistry are the observable half of weak retention, and the probe uses both:

weakref-deref-before -> true

new WeakRef(target) does not keep target alive, and deref() returns the target while something else still holds it. The probe checks the positive case first. The negative case only happens after the collector has run, and a script can ask for that run: v8.setFlagsFromString('--expose-gc') followed by vm.runInNewContext('gc') produces a working gc function in a plain Node process where globalThis.gc is undefined.

A requested run is still not enough to see undefined inside the same job, and one requested run is not enough across jobs either. A second probe drops three targets, calls gc, reads each reference immediately, then calls gc again a turn later and reads again:

deref-shape -> same_job=true after_second_gc=false | same_job=true after_second_gc=false | same_job=true after_second_gc=false

Every reference still answered in the job that requested the collection, and every one answered undefined only after the second requested run. The first run marks the objects collectable; the reference still answers until a later collection actually reclaims them, so a test that asserts undefined right after one gc call fails for a reason that has nothing to do with retention. The same probe shows a FinalizationRegistry callback firing after the requested run, which is why a registry callback and a deref() check both belong in a test that wants to prove collection.

FinalizationRegistry is the callback form: the page registers a target with a held value, and the registry calls back with that held value after the target is collected. The callback is not a promise and does not run on a schedule the page controls. It can run much later, or not at all before the page unloads, so a page that depends on it for correctness has a correctness bug rather than a performance one.

The pair is useful for diagnostics, not for cleanup, and the order in which a diagnostic callback finally runs is a scheduling question covered by microtasks and timers. A page that wants to know whether its element cache is actually shrinking can register each key with a counter and log the callback, which turns "I think this is collected" into a number. A page that wants to release a resource uses an explicit close, dispose or AbortController, because those run when the page says so rather than when the collector decides.

There is one more asymmetry worth recording. A WeakRef can be created for any object, including one the page still holds strongly, and the reference adds no retention of its own. A page that creates a WeakRef per element and keeps the references in an array has kept an array of small objects, which is usually fine, but the array itself is strong and grows with the number of elements the page has ever seen. The probe's single reference hides that, and a review of a real page should count the references, not just the targets.

What a weak collection cannot survive

A weak collection is not data. It cannot be copied, logged or sent, and the probe shows both failures:

serialization -> json=ok:{} structuredClone=throws:DOMException

JSON.stringify does not throw; it produces {}, because a WeakMap has no own enumerable properties. That is the more dangerous of the two, because the output is valid JSON and a caller who logs it sees an empty object rather than an error. A record that says "the cache was empty" can therefore be produced by a full cache, and nothing in the output distinguishes the two.

structuredClone throws a DOMException instead, which is the honest answer: a weak collection has no clone semantics, because a clone would have to decide whether the copy is weak in the same way. A page that passes a WeakMap through a worker boundary or through postMessage gets that exception rather than a silent empty object, and that is the failure mode worth designing for.

The same limit applies to a plain object with a WeakMap inside it. JSON.stringify walks the own properties, finds the weak collection, and produces {} for that field while the rest of the object serializes normally. The probe's single collection is the simple case; a real message body usually has the collection nested, and the empty field is easy to miss in a large log line.

Record a retention reading

A retention reading supports a narrow claim: this runtime, these key types, this reference structure, produced these observations. Five fields keep it checkable:

  1. The runtime, because the set of accepted key types changed when symbols became valid weak keys.
  2. The key types tried, because a TypeError at set time and a silent miss at get time are different results.
  3. The strong references the page still holds, because any one of them keeps every entry alive.
  4. Whether the collector was asked to run, because deref() returning a value proves nothing about collection.
  5. The serialization path, because JSON.stringify returns an empty object while structuredClone throws.

Public sources

Practical conclusion

Use a weak collection to attach data to an object you already hold, and expect nothing else from it: no size, no enumeration, no serialization, and no cleanup that runs on your schedule. Normalize keys to objects before they reach the map so an invalid key fails at the write rather than disappearing at the read. Audit the strong side of the page, because one retained array defeats every weak entry it lists. When you need release, call an explicit dispose path, and keep WeakRef and FinalizationRegistry for diagnostics. For what survives a copy of the data you were trying to store, read structuredClone versus JSON; for the order the callbacks that touch those entries run in, read microtasks and timers.

#WeakMap#WeakSet#WeakRef#FinalizationRegistry#structuredClone

This article is part of the guide: What Is a Browser Fingerprint?

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.