Controlling Garbage Collection in JavaScript
Memory leaks in JavaScript rarely happen by accident, they happen because of strong references that keep objects pinned to the heap even though the code no longer needs them. WeakRef and FinalizationRegistry are the official JavaScript APIs for cooperating with the garbage collector and releasing resources cleanly, without undermining the foundation of automatic memory management.
Table of Contents
- 1. How garbage collection really works in V8
- 2. Typical causes of memory leaks in JavaScript
- 3. WeakRef: weak references and their limits
- 4. FinalizationRegistry: cleaning up when the GC strikes
- 5. Practical pattern: WeakRef-based cache
- 6. WeakMap and WeakSet compared to WeakRef
- 7. Pitfalls: when WeakRef and FinalizationRegistry hurt
- 8. Memory profiling in the browser and Node.js
- 9. Comparison: strong vs. weak reference strategies
- 10. Summary
- 11. FAQ
1. How garbage collection really works in V8
JavaScript's automatic memory management is based on a generational garbage collector. V8, the JavaScript engine in Chrome and Node.js, splits the heap into two areas: the Young Generation area (also called the minor heap) for newly allocated objects, and the Old Generation area (major heap) for long-lived objects. Most objects die young, that is the generational hypothesis. The minor GC runs frequently and quickly, the major GC rarely and expensively. A WeakRef target can disappear after any GC cycle, which is the core contract of this API.
The GC uses the reachability algorithm: an object is alive if it is reachable from a GC root. GC roots are global variables, call stack variables, active closures, and event listeners. A strong reference keeps an object alive as long as the reference itself is alive. A WeakRef does not keep the target object alive, the GC is allowed to collect it even if a WeakRef still points to it. That sounds simple, but it has far-reaching consequences for programming.
2. Typical causes of memory leaks in JavaScript
The most common cause of memory leaks in JavaScript is event listeners that are never removed. When a component is destroyed but its event listeners are still registered on DOM elements, the DOM element holds a reference to the closure, the closure holds a reference to the component, and the GC cannot free anything. The same applies to timers using setInterval that are never stopped with clearInterval, and closures that reference large amounts of data. In single-page applications, where components are mounted and destroyed frequently, these leaks add up.
A second classic pattern: global caches with no eviction strategy. A Map object used as a cache that never removes entries grows without bound. In long-running Node.js servers, this can lead to significant memory growth. Here WeakRef is an elegant solution: if the cache key is an object and the weak reference to the value does not obstruct the GC, the GC can collect the value under memory pressure, and the cache shrinks automatically. This is not a replacement for an explicit LRU cache strategy, but it is a useful safety net.
// WeakRef-based cache, values can be GC'd when memory is needed
class WeakCache {
#store = new Map()
#registry
constructor() {
// FinalizationRegistry cleans up dead Map entries after GC
this.#registry = new FinalizationRegistry((key) => {
const ref = this.#store.get(key)
// Only delete if the WeakRef is actually dead (not replaced)
if (ref !== undefined && ref.deref() === undefined) {
this.#store.delete(key)
console.log(`[WeakCache] Entry for key "${key}" was collected by GC`)
}
})
}
set(key, value) {
const ref = new WeakRef(value)
this.#store.set(key, ref)
// Register the value for cleanup, passes key as held value to callback
this.#registry.register(value, key, ref)
return this
}
get(key) {
const ref = this.#store.get(key)
if (!ref) return undefined
// deref() returns the object or undefined if GC has collected it
return ref.deref()
}
has(key) {
return this.get(key) !== undefined
}
get size() {
// Count only live entries
let count = 0
for (const ref of this.#store.values()) {
if (ref.deref() !== undefined) count++
}
return count
}
}
const cache = new WeakCache()
let bigObject = { data: new Array(1000).fill('expensive computation result') }
cache.set('result', bigObject)
console.log(cache.get('result')) // { data: [...] }
bigObject = null // drop strong reference, GC may now collect it
3. WeakRef: weak references and their limits
A WeakRef object is created with new WeakRef(target) and returns, via weakRef.deref(), either the target object or undefined, depending on whether the GC has already collected the object. The central misunderstanding about WeakRef: there is no guarantee when the GC collects the target object. Within a synchronous execution between two microtask checkpoints, an object stays alive. But between event loop iterations, and especially after memory pressure, the GC can strike. Code that must handle undefined after a deref() call is a hard requirement for robustness.
Proper use of WeakRef therefore always requires the same calling pattern: call deref() once, store the result in a local variable, and check it before using it. Calling deref() multiple times is dangerous, because the GC could act between the calls. Important: WeakRef targets must be objects, primitives like strings, numbers, or booleans are not managed by the GC in the first place and cannot serve as a WeakRef target.
4. FinalizationRegistry: cleaning up when the GC strikes
FinalizationRegistry is the counterpart to WeakRef for active notifications. Instead of polling whether a WeakRef target is still alive, you register an object and a callback. When the GC collects the object, the JavaScript engine invokes the callback with an arbitrary chosen "held value". A typical use case: releasing external resources such as file handles, native objects, or database connections that are not managed by JavaScript's automatic memory management.
The most important restriction of FinalizationRegistry: the callback is not executed immediately when the object is collected, it runs at some point in a future event loop tick. The specification gives no timing guarantees. Programs must not rely on the cleanup callback running before a specific operation. FinalizationRegistry is a best-effort mechanism for supplementary cleanup, not a replacement for explicit resource management with try/finally or the using keyword (Explicit Resource Management, ES2024).
// FinalizationRegistry for tracking native/external resource cleanup
class NativeResourceManager {
#registry
#openHandles = new Map()
constructor() {
this.#registry = new FinalizationRegistry(({ id, type }) => {
// Called when the associated JS object is GC'd
// WARNING: this is best-effort, not guaranteed timing
console.warn(`[ResourceManager] Leaked ${type} resource id=${id}, cleaning up`)
this.#forceClose(id)
})
}
// Register a resource wrapper object for tracking
track(resourceObject, id, type = 'generic') {
this.#openHandles.set(id, { type, opened: Date.now() })
// third arg = unregister token, allows explicit unregistration
this.#registry.register(resourceObject, { id, type }, resourceObject)
}
close(resourceObject, id) {
// Explicit close: unregister from FinalizationRegistry
this.#registry.unregister(resourceObject)
this.#openHandles.delete(id)
}
#forceClose(id) {
const info = this.#openHandles.get(id)
if (info) {
// Emit metrics / alert monitoring for leaked resources
console.error(`Leaked resource: id=${id}, type=${info.type}, age=${Date.now() - info.opened}ms`)
this.#openHandles.delete(id)
}
}
get leakCount() { return this.#openHandles.size }
}
// Usage pattern with explicit resource management
const manager = new NativeResourceManager()
function openDbConnection(id) {
const conn = { id, query: async (sql) => { /* ... */ } }
manager.track(conn, id, 'db-connection')
return conn
}
5. Practical pattern: WeakRef-based cache
The most common sensible use case for WeakRef is a secondary cache that memoizes expensive computations but releases memory under pressure. The classic example: an image decoding cache in a web application. Images are decoded on first load and held in the cache as ImageBitmap objects. When the browser needs memory, it is allowed to release these objects, and the next access to the same image triggers a new decode, which is a bit slower but correct. This is a classic speed-memory tradeoff that WeakRef makes elegant to implement.
An important practical note for WeakRef caches: under certain circumstances the GC can collect objects that a developer still considers "active", especially when the only path to the object is through a WeakRef and no other strong reference exists. In debugging sessions this happens less often, because the DevTools debugger itself holds references. Production behavior and debugging behavior can therefore differ. This difference needs to be accounted for in tests.
6. WeakMap and WeakSet compared to WeakRef
WeakMap and WeakSet have existed since ES2015 and are the better choice for most weak reference use cases compared to WeakRef. The difference: WeakMap and WeakSet hold weak references to their keys (WeakMap) or members (WeakSet), but strong references to their values. They are ideal for annotating objects with additional metadata without obstructing the GC. When the key of a WeakMap entry is collected, the entry disappears automatically.
WeakRef, on the other hand, holds a weak reference to any arbitrary object, independent of any map structure. It is the more flexible but also more dangerous API. The main difference in practice: WeakMap entries can never be explicitly iterated (no .keys(), no .size), which makes them unsuitable for caches that need key iteration. WeakRef can be stored in a regular Map, which is iterable, hence the WeakCache implementation from the earlier example. FinalizationRegistry then takes care of cleaning up stale map entries.
| Feature | WeakMap/WeakSet | WeakRef | FinalizationRegistry |
|---|---|---|---|
| Introduced | ES2015 | ES2021 | ES2021 |
| Weak ref to | Keys (Map) / Members (Set) | Any object | Registered object |
| Iterable | No | Yes (via Map) | N/A |
| GC callback | No | No | Yes |
| Typical use | Annotating object metadata | Optional cache | Resource cleanup |
7. Pitfalls: when WeakRef and FinalizationRegistry hurt
The biggest pitfall of WeakRef: it tempts you to solve memory management problems that could actually be solved with cleaner code design. When a class stores objects as WeakRef because it is not sure whether those objects are still alive, that is often a sign of an ownership problem in the code design. Explicit lifecycle methods like mount() and destroy() are usually the cleaner solution compared to WeakRef-based heuristics.
Another problem: FinalizationRegistry callbacks can execute at critical moments and have side effects that disturb normal control flow. In Node.js environments with worker threads, the execution order of registry callbacks is even less deterministic. Also, the more WeakRef and registry objects that exist in a program, the more work the GC has to do to determine reachability. Heavy use of these APIs can, paradoxically, degrade GC performance.
8. Memory profiling in the browser and Node.js
Before reaching for WeakRef or FinalizationRegistry, the actual memory problem should be verified with profiling tools. In Chrome DevTools, the Memory tab offers heap snapshots that show which objects occupy how much memory and from where they are referenced. The "Retainers" graph shows the reference chain that keeps an object alive. For memory leaks, comparing two snapshots (before and after a user action) is the most effective approach: objects that appear in the delta and keep growing are the candidates.
In Node.js, process.memoryUsage() is available for simple measurements. For detailed profiling, the --inspect flag provides the Chrome DevTools connection. The npm package heapdump enables programmatic heap snapshots. With v8.writeHeapSnapshot() (built in since Node.js 11), you can trigger snapshots from code. An important note for testing WeakRef behavior: in Node.js you can trigger a GC manually with --expose-gc and the global gc() function call, only for tests, never in production code.
// Memory profiling utilities for WeakRef / FinalizationRegistry debugging
// Node.js: expose GC for testing (run with: node --expose-gc test.js)
async function testWeakRefBehavior() {
let target = { payload: new Array(1e6).fill(0) } // ~8 MB
const ref = new WeakRef(target)
console.log('Before drop:', ref.deref() !== undefined) // true
target = null // drop strong reference
// Force GC (only works with --expose-gc flag!)
if (typeof gc === 'function') {
gc()
// Wait for GC to process, it runs async relative to JS
await new Promise(resolve => setTimeout(resolve, 100))
}
console.log('After GC:', ref.deref()) // undefined (or still the object if GC hasn't run)
// Proper pattern: always check deref() result before use
const obj = ref.deref()
if (obj === undefined) {
console.log('Object was collected, fallback to recomputation')
return recompute()
}
return obj
}
// Browser: track memory with performance.measureUserAgentSpecificMemory()
async function measureMemory() {
if ('measureUserAgentSpecificMemory' in performance) {
const result = await performance.measureUserAgentSpecificMemory()
console.log('JS Heap:', result.bytes / 1024 / 1024, 'MB')
console.log('Breakdown:', result.breakdown)
}
}
9. Comparison: strong vs. weak reference strategies
The choice between strong references, WeakMap, WeakRef, and explicit lifecycle management depends on the concrete use case. For annotating DOM elements with event handler references, WeakMap is ideal: when the element is removed from the DOM and all other references to it disappear, the cache entry disappears automatically too. For secondary caches with string keys, WeakRef in a regular Map is the better choice, because WeakMap only allows objects as keys.
Explicit lifecycle management, meaning component.destroy() methods that remove event listeners and clear caches, is the most reliable strategy in every case where it is possible. WeakRef and FinalizationRegistry are meant for the cases where explicit cleanup cannot be guaranteed: third-party code that registers callbacks, objects whose lifecycle is controlled externally, or resources that need to be held as a safety net in case the explicit cleanup path fails.
10. Summary
WeakRef and FinalizationRegistry are powerful but specific tools in the JavaScript toolbox. WeakRef enables holding a reference to an object without preventing its GC, ideal for secondary caches and optional resources. FinalizationRegistry enables cleanup callbacks when the GC collects an object, ideal for native resource management and leak detection. Both APIs require the calling code to deal with the non-determinism of GC execution and never rely on a specific timing.
For most cases where weak references seem useful, WeakMap and WeakSet are the simpler and safer alternatives. WeakRef and FinalizationRegistry should only be used once these simpler alternatives are not sufficient. Diagnose memory management problems with profiling tools first, then choose the minimal abstraction needed, and always prefer explicit cleanup. WeakRef is the safety net, not the first thing to reach for.
WeakRef and FinalizationRegistry: The Essentials at a Glance
WeakRef basic rule
Always call deref() once, check the result, then use it. Never trust an undefined result unconditionally, GC timing is not deterministic.
FinalizationRegistry
Best-effort cleanup, not a replacement for explicit resource management. Callbacks run at some point after the GC, with no timing guarantee.
Prefer WeakMap
For object metadata, WeakMap is simpler and safer than WeakRef. Only switch to WeakRef when iteration over the keys is needed.
Profile first
Use a heap snapshot in Chrome DevTools or --expose-gc in Node.js before introducing WeakRef. Understand the problem first, then solve it.
Mironsoft
JavaScript performance, memory management, and architecture consulting
Finding memory leaks in your JavaScript application?
We analyze your application with heap snapshots, identify memory leaks, and implement clean solutions, from explicit cleanup and WeakMap to WeakRef-based caches.
Memory Audit
Heap snapshot analysis, retainer graph, and identification of leak sources
Cleanup Strategies
Explicit lifecycle management and WeakRef patterns tailored to your use case
Performance
Reduce GC pressure and stop heap growth with clean reference strategies