Actually using the FinalizationRegistry API in JavaScript
I love following the ever-evolving ECMAScript spec, but lately some of the features coming out have been a bit esoteric.
One thing I really struggled to wrap my head around was the new APIs for garbage collection, but I really wanted to figure out how to take advantage of them.
Well, I’m happy to say I was able to figure out how to make use of the FinalizationRegistry APIs with a real, practical example!
Aside
WeakSet and WeakMap have been around for a while, and they’re pretty simple to use. They often make sense to use in conjunction with classes. For example, you can track data associated with instances of a class:
type PrivateUserData = {
password: string;
};
const privateUserData = new WeakMap<User, PrivateUserData>();
class User {
email: string;
constructor(email: string, password: string) {
this.email = email;
privateUserData.set(this, { password });
}
changePassword(oldPassword: string, newPassword: string): void {
const privateData = privateUserData.get(this);
if (oldPassword !== privateData.password) {
throw new Error("The current password you entered is incorrect");
}
privateData.password = newPassword;
}
}
The above example is actually an attempt to use WeakMaps to compensate for JavaScript’s lack of “private” instance properties. Now JavaScript actually has a native syntax for private properties:
class User {
email: string;
#password: string;
constructor(email: string, password: string) {
this.email = email;
this.#password = password;
}
changePassword(oldPassword: string, newPassword: string): void {
if (oldPassword !== this.#password) {
throw new Error("The current password you entered is incorrect");
}
this.#password = newPassword;
}
}
Private properties are a more natural way to associate data with an object while preventing access, but both of these methods rely on the same underlying mechanism — when a reference to the object is lost, the data is garbage-collected and becomes inaccessible.
Yes, that seems obvious, but this is the beginning to the answer for why classes are an important concept for correct usage of garbage-collection. For example, consider this factory-function style:
type User = {
getEmail: () => string;
setEmail: (email: string) => void;
changePassword: (oldPassword: string, newPassword: string) => void;
};
function createUser(email: string, password: string): User {
return {
getEmail: () => email,
setEmail: (newEmail) => {
email = newEmail;
},
changePassword: (oldPassword, newPassword) => {
if (oldPassword !== password) {
throw new Error("The current password you entered is incorrect");
}
password = newPassword;
},
};
}
There’s a reason why this style is quite popular: it has no usage of this
. Instead, we have a local variable password
which remains in scope. That means we can destructure this object and it still works:
const { getEmail, setEmail, changePassword } = createUser("", "");
Without a this
, there is not an object that we can directly track for garbage collection!
On the other hand, JavaScript objects that rely on the prototype and value of this
need to keep a reference to the object around:
// the "factory" approach -- we don't have to keep the object returned from
// the `createUser` function around
const { getEmail, setEmail } = createUser("", "");
setEmail("foo@bar.baz");
console.log(getEmail()); // "foo@bar.baz"
// the class instance requires us to keep the object in scope
const user = new User("", "");
user.email = "foo@bar.baz";
console.log(user.email);
const { changePassword } = user; // ❌ doesn't work!!
const changePassword = user.changePassword.bind(user);
So, when would we care about garbage collection?
I struggled for quite a while to figure out how I could use the new FinalizationRegistry API for something practical when I finally stumbled upon a great use case that comes up in the real world!
You’re probably familiar with promises. You might be familiar with deferreds, but if not, don’t panic:
In JavaScript, you usually use other APIs which return promises and rarely use the Promise constructor yourself. But you can turn any asynchronous event into a promise with a little bit of creativity. For example, you can use the Promise constructor to create a simple “sleep” function:
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => resolve(), ms);
});
}
Now imagine a more complex scenario: what if we wanted to create a function that returned a promise, and the promise would not resolve until some other event occurred, like a user clicking a button:
const alertModal = document.createElement("div");
alertModal.setAttribute("id", "alertModal");
alertModal.style.display = "none";
alertModal.innerHTML = '<div class="message"></div><button>OK</button>';
document.body.appendChild(alertModal);
const alertMessage = alertModal.querySelector(".message");
const dismissButton = alertModal.querySelector("button");
function alert(message: string): Promise<void> {
return new Promise<void>((resolve) => {
alertMessage.innerText = message;
alertModal.style.display = "block";
dismissButton.addEventListener("click", onDismiss);
function onDismiss(): void {
alertModal.style.display = "none";
dismissButton.removeEventListener("click", onDismiss);
resolve();
}
});
}
You can start to see a pattern here: regardless of what asynchronous logic is occurring, our code has to be invoked inside the callback passed to the Promise constructor so we have access to the resolve function. It starts to get cumbersome, and you might run into boundaries that become a problem. Let’s say you’re in Reactland, and you want to make a hook that waits for a similar kind of event:
type AlertContext = {
visible: boolean;
open: (message: string) => void;
};
const Context = createContext<AlertContext>(undefined as any);
function AlertProvider({ children }: { children: ReactNode }) {
const [visible, setVisible] = useState(false);
const [message, setMessage] = useState<ReactNode>(null);
const open = useCallback((message: string): void => {
setVisible(true);
setMessage(message);
}, []);
const close = useCallback((): void => {
setVisible(false);
}, []);
const context = useMemo<AlertContext>(
() => ({ visible, open }),
[visible, open],
);
return (
<>
{children}
<Modal visible={visible} onClose={close}>
{message}
</Modal>
</>
);
}
function useAlert(): (message: string) => Promise<void> {
const { visible, setMessage, setVisible } = useContext(AlertContext);
const alert = useCallback((message: string): Promise<void> => {
setVisible(true);
return new Promise((resolve) => {
// this is the place where we have access to `resolve`
});
}, [setMessage, setVisible]);
useEffect(() => {
if (!visible) {
// ???
// this is the place where we want to call `resolve`
}
}, [visible]);
return alert;
}
Since this is a common pattern, it makes sense to create an abstraction, hence the Deferred pattern. A Deferred object creates a new promise and provides its resolve and reject functions as methods.
class Deferred<T> {
readonly promise: Promise<T>;
readonly resolve!: (value: T) => void;
readonly reject!: (reason?: unknown) => void;
constructor() {
this.promise = new Promise<T>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
function alert(message: string): Promise<void> {
const deferred = new Deferred<void>();
alertMessage.innerText = message;
alertModal.style.display = "block";
dismissButton.addEventListener("click", onDismiss);
function onDismiss(): void {
alertModal.style.display = "none";
dismissButton.removeEventListener("click", onDismiss);
deferred.resolve();
}
return deferred.promise;
}
As you can see, by exposing the resolve and reject callbacks in this way, we have a single instance that we can “lift up” if we were using React:
const { visible, open } = useContext(AlertContext);
// will wrap the "async operation" that begins when the modal is opened
// and resolves when user dismisses the modal. it is null when the modal
// is not open
const deferred = useRef<Deferred<void> | null>(null);
useEffect(() => {
if (!visible) {
// once dismissed, resolve the promise
deferred.current?.resolve();
deferred.current = null;
}
}, [visible]);
const alert = useCallback((message: string): Promise<void> => {
if (deferred.current) {
throw new Error("an alert modal is already open");
}
// initialize our deferred
deferred.current = new Deferred();
// show the modal
open(message);
// return the promise
return deferred.current.promise;
}, [open]);
Now with the way this class is right now, there’s no need for the consumer to keep a reference to the object around. We’re providing direct access to the resolve and reject callbacks.
However, we could tweak this class to make it so that the consumer has to hold onto the reference and can’t just destructure it. If we do, then we might be able to use the FinalizationRegistry API to know when the Deferred object gets garbage-collected.
So why would we care?
Consider that a Deferred object must remain accessible in order to resolve or reject its wrapped Promise. A function could create a deferred object, return the created promise, and then throw away the reference, leaving the new promise in a permanently pending state. Any functions that are waiting on that promise are stuck waiting forever.
Does this happen in real life? Absolutely. Imagine a scenario where the deferred pattern is applied to wait for some kind of event to happen — maybe a developer wrote a piece of code that was waiting for a new auth token before sending an API request. What they didn’t anticipate is the user clicking “log out” while they were awaiting a new token, causing the request to get abandoned while other elements on the page continue to wait for the promise.
If we could track when a Deferred object is garbage-collected, and if we also knew that the resolve or reject function never got called before garbage collection, we could alter that behavior. Rather than suspending those other functions’ executions forever, we could reject the promise!
Let’s start by making some changes to the Deferred class so that we’ll be able to know when it’s no longer possible for it to be fulfilled.
class Deferred<T> {
readonly #promise: Promise<T>;
readonly #resolve: (value: T) => void;
readonly #reject: (reason?: unknown) => void;
constructor() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
this.#promise = new Promise<T>((res, rej) => {
res = resolve;
rej = reject;
});
this.#resolve = resolve;
this.#reject = reject;
}
get promise(): Promise<T> {
return this.#promise;
}
resolve(value: T): void {
this.#resolve(value);
}
reject(reason?: unknown): void {
this.#reject(reason);
}
}
With this change, the actual callbacks to resolve or reject the promise are private members on the object. In order to actually call resolve or reject, the method will need to stay bound to the original instance of the Deferred class.
Now to the tricky part — we want to be able to react to when the Deferred object is garbage-collected. We have to be extra careful that we don’t accidentally create any permanent references in our own code that would cause the this
object to stick around in memory forever.
First, let’s create an Error class to represent the exception that occurs when our Deferred gets garbage-collected without resolution:
class DeferredGarbageCollectedError extends Error {
readonly name: "DeferredGarbageCollectedError" =
"DeferredGarbageCollectedError";
constructor() {
super(
"Deferred object was garbage-collected without " +
"resolving or rejecting the promise",
);
}
}
The FinalizationRegistry API lets us associate some other metadata with an object. When the object gets garbage-collected, a callback is called with the metadata associated with that object. So in our case, the metadata will need to be a function which will reject our promise. This function can’t rely on our Deferred object or it would never be garbage-collected, so we will pass it the reject
method directly.
// The metadata ("held value") will be a function to reject the promise
type HeldValue = () => void;
const registry = new FinalizationRegistry<HeldValue>(
(callback) => callback(),
);
class Deferred<T> {
readonly #promise: Promise<T>;
readonly #resolve: (value: T) => void;
readonly #reject: (reason?: unknown) => void;
constructor() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
this.#promise = new Promise<T>((res, rej) => {
res = resolve;
rej = reject;
});
this.#resolve = resolve;
this.#reject = reject;
// We register this Deferred instance and provide the callback
// to reject the promise
registry.register(
this, // the value we are tracking for GC
() => reject(new DeferredGarbageCollectedError()),
// the "held value" references locally scoped `reject`
// rather than `this.#reject` so it has no dependency on
// `this` and won't prevent GC
);
}
get promise(): Promise<T> {
return this.#promise;
}
resolve(value: T): void {
this.#resolve(value);
// When resolve or reject are called, we no longer care
// about garbage collection
registry.unregister(this);
}
reject(reason?: unknown): void {
this.#reject(reason);
// When resolve or reject are called, we no longer care
// about garbage collection
registry.unregister(this);
}
}
To figure out if this actually worked, I created a simple demo application. Check it out for yourself!