TypeScript WeakRef and FinalizationRegistry: Type-Safe Patterns for Memory-Sensitive Caches
Production-ready patterns for building memory-sensitive caches with TypeScript's WeakRef and FinalizationRegistry APIs—avoiding memory leaks while maintaining type safety.
TypeScript WeakRef and FinalizationRegistry: Type-Safe Patterns for Memory-Sensitive Caches
Most memory leak problems in JavaScript applications stem from caching layers that hold strong references to objects that should have been garbage collected. The pattern teams overlook is that TypeScript's WeakRef and FinalizationRegistry APIs provide deterministic control over weak references and cleanup callbacks—enabling memory-sensitive caches that release resources automatically when memory pressure increases.
Traditional caches hold strong references. When an application loads thousands of images or maintains connection pools, these strong references prevent the garbage collector from reclaiming memory even when the objects are no longer actively used. The cache becomes a memory sink that grows unbounded until the application crashes or slows to a crawl.
flowchart LR
A("Cache stores strong reference") --> B("Object stays in memory")
B --> C("Memory pressure increases")
C --> D("application crashes from OOM")
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
WeakRef solves this by allowing caches to hold references that the garbage collector can clear under memory pressure. FinalizationRegistry adds cleanup callbacks that fire when objects are collected—enabling resource release without manual intervention. Together they form a memory-sensitive caching pattern that adapts to runtime conditions.
flowchart LR
A("Cache stores strong reference") --> B("WeakRef allows GC reclaim")
B --> C("Memory pressure increases")
C --> D("GC clears weak refs automatically")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This matters because modern applications handle thousands of transient objects—image thumbnails in photo galleries, parsed responses in API clients, compiled templates in rendering engines. Without weak references these objects accumulate until memory exhaustion. With weak references they disappear automatically when no longer needed.
Key Takeaways
WeakRefallows caches to hold references that the garbage collector can reclaim under memory pressure, preventing unbounded memory growth.FinalizationRegistryprovides cleanup callbacks when objects are collected, enabling automatic resource release for file handles, network connections, and external resources.WeakRefdiffers fromWeakMapandWeakSetin that it allows holding references to values without requiring them as keys, making it suitable for caches where the key is a string or number.- Production caches require dereference guards—every
weakRef.deref()call can returnundefinedif the garbage collector has run since the reference was stored. WeakRefis unsuitable for critical data that must survive—use it only for derived values, cached computations, or resources that can be recreated on demand.
Understanding WeakRef: Holding References Without Preventing Garbage Collection
WeakRef<T> holds a weak reference to a target object of type T. The reference does not prevent garbage collection—if the target has no other strong references, the collector reclaims it and subsequent deref() calls return undefined.
The critical distinction is that WeakRef holds the reference directly. In contrast, WeakMap and WeakSet use objects as keys and become useless when those keys are collected. WeakRef allows holding references to objects while using primitive keys like strings or numbers—essential for caches keyed by IDs or URLs.
flowchart TD
A("Strong reference created") --> B("Object in memory")
B --> C{"Any strong refs remain?"}
C -->|Yes| B
C -->|No| D("GC marks for collection")
D --> E("WeakRef.deref returns undefined")
style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
TypeScript requires explicit null checks after dereferencing because the type system understands that deref() returns T | undefined. This forces developers to handle the case where the garbage collector has cleared the reference—a pattern that prevents the silent bugs that plague untyped weak reference code.
The implication here is that every code path using WeakRef must guard against undefined. This is not a TypeScript limitation—it is the correct model. The garbage collector can run at any time, and code that assumes a weak reference will always succeed is broken code.
In other words, WeakRef shifts the burden from "remember to clean up" to "handle the case where cleanup already happened." The failure mode becomes explicit and type-checked rather than a silent memory leak that surfaces only in production under load.
Building a Type-Safe Cache with WeakRef in TypeScript
A type-safe cache with WeakRef requires three components: a storage map keyed by primitives, weak references to cached values, and dereference guards that handle the undefined case. The pattern below shows the minimal structure.
class WeakRefCache<K extends string | number, V extends object> {
private cache = new Map<K, WeakRef<V>>();
set(key: K, value: V): void {
this.cache.set(key, new WeakRef(value));
}
get(key: K): V | undefined {
const ref = this.cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
// Reference was cleared by GC - clean up the entry
this.cache.delete(key);
return undefined;
}
return value;
}
has(key: K): boolean {
return this.get(key) !== undefined;
}
delete(key: K): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
}
// Usage with image cache
interface ImageData {
bitmap: ImageBitmap;
width: number;
height: number;
}
const imageCache = new WeakRefCache<string, ImageData>();
async function loadImage(url: string): Promise<ImageData> {
// Check cache first
const cached = imageCache.get(url);
if (cached) return cached;
// Load and cache
const response = await fetch(url);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
const imageData: ImageData = {
bitmap,
width: bitmap.width,
height: bitmap.height,
};
imageCache.set(url, imageData);
return imageData;
}The get method demonstrates the essential dereference guard pattern. When deref() returns undefined, the method deletes the stale entry from the map—preventing the cache from accumulating dead WeakRef wrappers. Without this cleanup, the map grows unbounded even though the values are gone.
This distinction is critical. A WeakRef itself is a strong reference to a small wrapper object. If the cache never removes these wrappers, it leaks memory—not the cached values but the metadata. The cleanup in get() solves this by removing stale entries on access.
The type constraint V extends object is necessary because WeakRef only accepts object types. Primitives cannot be weakly referenced because they do not participate in garbage collection—they are either interned or copied by value. This constraint surfaces at compile time rather than runtime.
FinalizationRegistry: Cleanup Callbacks When Objects Are Collected
FinalizationRegistry<T> registers cleanup callbacks that fire when registered objects are garbage collected. The registry maintains a weak reference to each registered object and invokes the callback with a held value of type T when collection occurs.
The held value is not the collected object—it is an arbitrary value provided at registration time, typically a resource handle or identifier. This matters because the collected object is gone by the time the callback runs. The callback cannot access it.
flowchart TD
A("Object registered with held value") --> B("Application uses object")
B --> C{"Strong refs removed?"}
C -->|No| B
C -->|Yes| D("GC collects object")
D --> E("Cleanup callback fires with held value")
E --> F("Release external resource")
style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The pattern below shows a resource pool that uses FinalizationRegistry to release file handles when pooled objects are collected. The held value is the file descriptor number—an identifier that survives collection.
interface PooledFile {
fd: number;
path: string;
read(buffer: Uint8Array): Promise<number>;
close(): Promise<void>;
}
class FilePool {
private registry = new FinalizationRegistry<number>((fd) => {
// Cleanup callback - close leaked file descriptor
console.warn(`Auto-closing leaked file descriptor ${fd}`);
this.closeFileDescriptor(fd);
});
private openDescriptors = new Set<number>();
async acquire(path: string): Promise<PooledFile> {
const fd = await this.openFileDescriptor(path);
this.openDescriptors.add(fd);
const file: PooledFile = {
fd,
path,
read: async (buffer) => this.readFromDescriptor(fd, buffer),
close: async () => {
this.openDescriptors.delete(fd);
await this.closeFileDescriptor(fd);
this.registry.unregister(file);
},
};
// Register for cleanup if file.close() is never called
this.registry.register(file, fd, file);
return file;
}
private async openFileDescriptor(path: string): Promise<number> {
// Platform-specific file opening
return Math.floor(Math.random() * 10000); // Stub
}
private async readFromDescriptor(
fd: number,
buffer: Uint8Array
): Promise<number> {
// Platform-specific read
return buffer.length; // Stub
}
private async closeFileDescriptor(fd: number): Promise<void> {
// Platform-specific close
this.openDescriptors.delete(fd);
}
getOpenCount(): number {
return this.openDescriptors.size;
}
}The register call takes three arguments: the target object to watch, the held value passed to the callback, and an unregister token used to cancel registration. When file.close() is called, unregister prevents the cleanup callback from firing—avoiding double-close bugs.
The implication here is that FinalizationRegistry is a safety net, not a primary cleanup mechanism. The callback fires only if the developer forgets to call close(). Correct code calls close() explicitly and never triggers the callback.
In other words, the registry catches mistakes rather than replacing explicit resource management. This is the opposite of destructors in languages like C++ or Rust, where cleanup always happens in a deterministic finalizer. JavaScript finalizers are non-deterministic and should only handle exceptional cases.
Production Pattern: Memory-Sensitive LRU Cache with Automatic Cleanup
Combining WeakRef and FinalizationRegistry produces a memory-sensitive LRU cache that evicts entries automatically under memory pressure while cleaning up external resources. The pattern below shows a cache for parsed API responses that tracks access order and registers cleanup callbacks.
interface CacheEntry<V> {
ref: WeakRef<V>;
lastAccess: number;
}
class MemorySensitiveLRU<K extends string | number, V extends object> {
private cache = new Map<K, CacheEntry<V>>();
private maxSize: number;
private registry: FinalizationRegistry<K>;
constructor(maxSize: number, onEvict?: (key: K, value: V) => void) {
this.maxSize = maxSize;
this.registry = new FinalizationRegistry((key) => {
// Value was garbage collected - remove stale entry
this.cache.delete(key);
});
}
set(key: K, value: V): void {
// Evict oldest entry if at capacity
if (this.cache.size >= this.maxSize) {
this.evictOldest();
}
const ref = new WeakRef(value);
this.cache.set(key, {
ref,
lastAccess: Date.now(),
});
// Register for automatic cleanup
this.registry.register(value, key, ref);
}
get(key: K): V | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
const value = entry.ref.deref();
if (!value) {
// GC cleared the value - remove stale entry
this.cache.delete(key);
return undefined;
}
// Update access time for LRU ordering
entry.lastAccess = Date.now();
return value;
}
private evictOldest(): void {
let oldestKey: K | undefined;
let oldestTime = Infinity;
for (const [key, entry] of this.cache.entries()) {
if (entry.lastAccess < oldestTime) {
oldestTime = entry.lastAccess;
oldestKey = key;
}
}
if (oldestKey !== undefined) {
this.cache.delete(oldestKey);
}
}
size(): number {
// Clean up stale entries before returning size
for (const [key, entry] of this.cache.entries()) {
if (!entry.ref.deref()) {
this.cache.delete(key);
}
}
return this.cache.size;
}
clear(): void {
this.cache.clear();
}
}
// Usage with API response cache
interface APIResponse {
data: unknown;
etag: string;
timestamp: number;
}
const responseCache = new MemorySensitiveLRU<string, APIResponse>(100);
async function fetchAPI(endpoint: string): Promise<APIResponse> {
const cached = responseCache.get(endpoint);
if (cached && Date.now() - cached.timestamp < 60000) {
return cached;
}
const response = await fetch(endpoint);
const data = await response.json();
const etag = response.headers.get("etag") || "";
const apiResponse: APIResponse = {
data,
etag,
timestamp: Date.now(),
};
responseCache.set(endpoint, apiResponse);
return apiResponse;
}The evictOldest method implements the LRU policy by finding the entry with the smallest lastAccess timestamp and removing it. This runs only when the cache reaches capacity—avoiding the overhead of maintaining a doubly-linked list on every access.
The FinalizationRegistry callback handles a different case: when the garbage collector clears a weak reference without the cache knowing. This happens when memory pressure forces collection before the cache's size limit triggers eviction. The callback removes the stale entry from the map, keeping metadata in sync.
This distinction is critical. Without the registry callback, the cache would accumulate dead WeakRef wrappers that deref() to undefined. The map's size would grow unbounded even though the values are gone. The callback prevents this by removing entries as soon as the GC clears them.
The failure mode here is subtle but expensive. A cache that leaks metadata consumes memory linearly with the number of unique keys ever accessed. For a long-running application with millions of cache keys over its lifetime, this becomes a significant leak even though the cached values themselves are collected.
WeakRef vs WeakMap vs WeakSet: When to Use Each
WeakRef, WeakMap, and WeakSet provide different weak reference semantics. Choosing the wrong one produces either memory leaks or type errors.
WeakMap<K extends object, V> uses objects as keys and allows any value type. When the key is collected, the map entry disappears. This is ideal for attaching metadata to objects without preventing their collection—for example, tracking DOM nodes without creating circular references.
WeakSet<T extends object> stores a set of objects weakly. When an object is collected, it disappears from the set. This is useful for tracking membership without ownership—for example, marking processed objects without preventing their collection.
WeakRef<T extends object> holds a weak reference to a single object and allows primitive keys. The reference can be dereferenced to access the object or undefined if collected. This is essential for caches keyed by strings or numbers.
flowchart LR
subgraph WeakMap["WeakMap usage"]
A1("Object key required") --> A2("Value can be any type")
A2 --> A3("Entry auto-removed when key collected")
end
subgraph WeakSet["WeakSet usage"]
B1("Object membership tracking") --> B2("No values stored")
B2 --> B3("Member auto-removed when collected")
end
subgraph WeakRef["WeakRef usage"]
C1("Primitive key allowed") --> C2("Value must be object")
C2 --> C3("deref returns undefined when collected")
end
style A3 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style B3 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style C3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The table below shows when to use each:
| Pattern | Use WeakMap | Use WeakSet | Use WeakRef |
|---|---|---|---|
| Cache keyed by object | Yes | No | No |
| Cache keyed by string/number | No | No | Yes |
| Track processed objects | No | Yes | No |
| Attach metadata to DOM nodes | Yes | No | No |
| Store derived values by ID | No | No | Yes |
The critical distinction is the key type. If the key is an object and you want the entry to disappear when that object is collected, use WeakMap. If the key is a primitive like a string or number, WeakMap cannot work—use WeakRef instead.
In other words, WeakMap ties lifetime to the key object. WeakRef ties lifetime to the value object but allows any key type. This makes WeakRef essential for caches where the key is a URL, user ID, or any other primitive identifier.
Real-World Use Cases: Image Caches, Resource Pools, and External Cleanup
Memory-sensitive caches appear in three production scenarios: large object caches, resource pools with external cleanup, and derived value storage.
Image caches benefit from WeakRef because decoded image bitmaps consume significant memory. A photo gallery application might load hundreds of thumbnails. Holding strong references prevents collection even when images scroll off-screen. Weak references allow the garbage collector to reclaim bitmaps under memory pressure while keeping frequently accessed images in memory.
flowchart LR
A("User scrolls gallery") --> B("Load thumbnail bitmaps")
B --> C("Store in WeakRef cache")
C --> D{"Memory pressure?"}
D -->|No| E("Bitmaps stay cached")
D -->|Yes| F("GC clears old bitmaps")
E --> A
F --> G("Reload if scrolled back into view")
G --> A
style F stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
Resource pools with external cleanup use FinalizationRegistry to release file handles, database connections, or network sockets when pooled objects are collected. The pattern below shows a database connection pool that closes leaked connections automatically.
interface PooledConnection {
id: number;
query<T>(sql: string): Promise<T>;
release(): Promise<void>;
}
class ConnectionPool {
private registry = new FinalizationRegistry<number>((id) => {
console.warn(`Auto-closing leaked connection ${id}`);
this.closeConnection(id);
});
private openConnections = new Map<number, any>();
private nextId = 1;
async acquire(): Promise<PooledConnection> {
const id = this.nextId++;
const rawConn = await this.openConnection();
this.openConnections.set(id, rawConn);
const conn: PooledConnection = {
id,
query: async (sql) => this.executeQuery(rawConn, sql),
release: async () => {
this.openConnections.delete(id);
await this.closeConnection(id);
this.registry.unregister(conn);
},
};
this.registry.register(conn, id, conn);
return conn;
}
private async openConnection(): Promise<any> {
return {}; // Platform-specific connection
}
private async executeQuery<T>(conn: any, sql: string): Promise<T> {
return {} as T; // Platform-specific query
}
private async closeConnection(id: number): Promise<void> {
const conn = this.openConnections.get(id);
if (conn) {
this.openConnections.delete(id);
// Platform-specific close
}
}
}Derived value storage uses WeakRef to cache expensive computations keyed by input identifiers. For example, a template engine might compile templates from source strings. The compiled output is derived from the source and can be recreated if collected. Weak references allow caching without preventing collection when memory is tight.
The pattern is identical to the image cache: store compiled templates in a WeakRefCache keyed by template ID. When get() returns undefined, recompile from source. The cache provides a performance win when memory is available and degrades gracefully under pressure.
This matters because the alternative is either unbounded memory growth or manual eviction policies that guess at optimal cache size. Weak references let the runtime decide based on actual memory availability—adapting automatically to device constraints and workload patterns.
Frequently Asked Questions
When should developers use WeakRef instead of WeakMap?
Use WeakRef when the cache key is a primitive like a string or number and the value is an object that should be collected under memory pressure. WeakMap requires object keys, making it unsuitable for caches keyed by URLs, IDs, or other primitives.
How do developers prevent the metadata leak from stale WeakRef entries?
Every get() method must delete map entries when deref() returns undefined. Without this cleanup, the cache accumulates dead WeakRef wrappers that consume memory even though the values are gone.
What happens if FinalizationRegistry cleanup callbacks throw errors?
Thrown errors are logged but do not crash the application. The garbage collector continues running and other callbacks still fire. However, the resource that should have been cleaned up remains leaked, so callbacks must never throw in production code.
Can developers rely on FinalizationRegistry for critical cleanup?
No—finalizer callbacks are non-deterministic and may fire seconds or minutes after collection, or not at all if the process exits. Use explicit cleanup like try/finally or the explicit resource management pattern for critical resources. FinalizationRegistry is a safety net for handling forgotten cleanup, not a primary mechanism.
Why does TypeScript require object types for WeakRef values?
Primitives like numbers and strings are not garbage collected as objects—they are either interned or copied by value. Weak references make sense only for heap-allocated objects that participate in collection. The extends object constraint prevents runtime errors from attempting to create weak references to primitives.
Pitfalls and Best Practices: When Not to Use WeakRef
The pattern works when cached values are truly optional and recreatable. It fails when values must survive or when dereferencing overhead exceeds caching benefits.
Never use WeakRef for critical application state. If a value must remain available—authentication tokens, user session data, configuration objects—hold a strong reference. Weak references introduce non-determinism that breaks correctness assumptions. The garbage collector can clear a weak reference at any time, including between a null check and dereference.
Never assume deref() will succeed. The pattern below is broken:
// BROKEN - race condition
const ref = cache.get(key);
if (ref) {
// GC can run here
const value = ref.deref(); // May return undefined
value.someMethod(); // Crash if GC ran
}The correct pattern guards every dereference:
// CORRECT - handle undefined
const value = cache.get(key);
if (value) {
value.someMethod(); // Safe - get() already dereferenced
}Never use WeakRef for objects with finalizers that must run deterministically. The FinalizationRegistry callback fires when the garbage collector runs, which is non-deterministic. For file handles, database connections, or other resources with cleanup requirements, use explicit cleanup with try/finally or the explicit resource management pattern described in TypeScript's using declaration.
The failure mode here is resource exhaustion. A connection pool that relies on finalizers might hold hundreds of open connections because the garbage collector has not run yet. Explicit cleanup ensures resources are released immediately when no longer needed.
For performance-critical code, measure dereference overhead. Every deref() call has a small cost, and hot paths that dereference thousands of times per second might see measurable slowdown. In these cases, a traditional strong-reference cache with explicit eviction may perform better despite higher memory usage.
That covers the essential patterns for memory-sensitive caches with WeakRef and FinalizationRegistry. Apply these in production and the difference will be immediate—unbounded memory growth from cached objects transforms into adaptive behavior that releases memory automatically under pressure. The caching benefits remain when memory is available, and graceful degradation happens transparently when it is not. For teams managing large object caches, resource pools, or derived value stores, these patterns are the difference between memory leaks and production-grade memory management.