ES2021 Overview Focusing on WeakRefs and Finalizers

2021. 8. 30.

ES2021 Overview Focusing on WeakRefs and Finalizers
image by Blake Connally

A few months ago, the final specification for ECMAScript 2021 was released. Some features are immediately usable, while others allow for deeper utilization of the language. It's good to see these changes and improvements each year, but perhaps I'm becoming desensitized, or the improvements aren't as impactful, as the interest and ripple effect seem less than before. I planned to summarize the spec as soon as I saw it, but much time has passed. It's already September... I've focused a bit more on WeakRefs and Finalizers, which might be somewhat unfamiliar.

Here's a summary:

  1. WeakRefs for creating weak references to objects.
  2. Finalizers to know when an object is garbage collected.
  3. Promise.any: Succeeds with the first resolved promise, or fails if all reject.
  4. String finally gets the replaceAll method.
  5. Logical assignment operators, which can be quite useful.
  6. Slightly awkward numeric separators.
  7. Stabilization of the Array.prototype.sort specification.

Some points might be understandable from the summary alone, so feel free to read only what interests you.

WeakRefs for Weak References

JavaScript can finally use weak references. I learned about their usefulness while studying Swift. While not exactly identical, most modern languages likely support similar concepts. For JavaScript, this means we can now selectively apply the garbage collection mechanism within our programs as needed.

Previously, JavaScript only had strong references. So, if any object or data was referenced by someone, it would never be targeted by garbage collection. This often led to mistakes where large, unnecessary pieces of information were kept in memory, causing memory leaks. Conversely, objects unknown to anyone could eventually be garbage collected. WeakRefs adds another referencing method: a weak reference, which knows about the object but allows it to be garbage collected. If an object is only referenced weakly, it's treated as if no one knows about it and can disappear at some point. Garbage collection implementation usually relies on reference counting; weak references allow referencing without incrementing this count.

const map = new Map();

const obj =  {data: new Array(10000).join('*')};

map.set('someData', obj);

setInterval(() => {
    // Accessing obj.data ensures it's strongly referenced and never GC'd
    // within the scope of this interval callback's closure.
    console.log(map.get('someData').data);
 }, 1000);

In the code above, the array of 10,000 characters referenced by obj.data uses a standard JavaScript strong reference. Therefore, the array will never be garbage collected and disappear from memory. The callback passed to setInterval creates a closure. This closure references map, and map references the object originally referenced by obj. Thus, the existence of the data is guaranteed every time the callback executes.

Now let's modify the code using WeakRef so that the object referenced by obj can become a target for garbage collection and be removed. We only need to change one line.

const map = new Map();

const obj = {data: new Array(10000).join('*')};

// Use WeakRef to create a weak reference
map.set('someData', new WeakRef(obj));

setInterval(() => {
    // Use deref() to access the weakly referenced object
    const ref = map.get('someData');
    const data = ref.deref()?.data; // Access data if object still exists
    if (data) {
        console.log(data);
    } else {
        console.log('Object has been garbage collected.');
        // Consider clearing the interval here
    }
 }, 1000);

WeakRef is essentially a constructor for creating weak reference objects. You use it with the new keyword. You can access the referenced target using the deref() method of WeakRef. If the referenced target has been garbage collected, deref() returns undefined.

Now, over time, the object, floating emptily and tenuously connected (referenced), will be devoured by the fearsome garbage collector. The setInterval callback no longer guarantees the object's existence. After about 7 seconds (depending on the GC implementation and environment), an error might occur in the original code if .data is accessed directly on undefined. The callback above handles this by checking the result of deref(). Note that the garbage collection timing can vary between browsers and depends on the specific GC implementation. The code above assumes it runs within a specific function scope. If run globally, obj might remain globally accessible, holding a strong reference and preventing GC, unless you explicitly set obj = null; after map.set.

To effectively use weak references in a program, knowing how to check if the object still exists is crucial. We're dealing with components that might or might not be there. The Finalizer concept allows monitoring when an object gets garbage collected. Let's move on to that next.

Finalizers to Know When an Object is Garbage Collected

Finalizers (FinalizationRegistry) can be seen as twins to WeakRefs. They don't always need to be used together, but Finalizers became necessary because of WeakRefs. WeakRefs introduced a new reference concept to JavaScript. Previously, once a reference was made, the object's existence was guaranteed unless the reference was explicitly broken. Now, we have a way to reference an object without guaranteeing its existence. If we want to utilize this concept of "might exist, might not exist" in our programs, we need a better way to know when the target disappears. Beyond checking each time we use it, we might need to perform necessary actions precisely when an object, susceptible to external factors, finally disappears. Finalizers allow us to capture that moment.

Using Finalizers is simple. It employs the observer or subscription pattern commonly used by DOM APIs. Essentially, you can use it like an event.

// Callback function to execute when a registered object is garbage collected
const cleanupCallback = (value) => { console.log(value); }

// Create a registry instance, passing the callback
const registry = new FinalizationRegistry(cleanupCallback);

I used descriptive variable names to aid understanding. FinalizationRegistry creates a finalizer, registering a callback function (cleanupCallback) to be executed when certain objects are garbage collected. This callback runs when the registered targetObjects are collected.

const targetObject = {};

// Register the object to be monitored
// Pass the value to be sent to the callback ('Object disappeared!')
// Pass a token (targetObject itself here) for potential unregistration
registry.register(targetObject, "Target object disappeared!", targetObject);

The first argument to the register method is the targetObject whose garbage collection timing we want to know. The second argument is the value that will be passed to the cleanupCallback when garbage collection occurs. This value can be any JavaScript type, not just a string. The third argument is a token used to unregister the finalizer if we are no longer interested in the targetObject. Typically, the target object itself is used as the token, but anything (as long as it's an object) works. This token can be used with unregister. It's like the function used to remove DOM event handlers.

registry.unregister(targetObject); // Use the same token provided during registration

If you want to group several objects and unregister them all at once, creating and reusing a dedicated token object for that group would be convenient.

The value passed as the second argument during register (the value for the callback) is held by a strong reference internally. Its delivery is always guaranteed. The token (third argument) is held by a weak reference. This differing reference strategy seems efficient and is a good example of WeakRef itself.

Let's modify the example used in the WeakRefs section to use a finalizer to do something when the obj object is garbage collected and disappears.

const map = new Map();
const obj = {data: new Array(10000).join('*')}; // Assume obj is defined

map.set('someData', new WeakRef(obj));

const finalizer = new FinalizationRegistry((v) => console.log(v));

// Register obj. When obj is GC'd, the callback receives 'obj went missing!'
// Use 'map' as the unregistration token
finalizer.register(obj, 'obj went missing!', map);

Simple, right? Here, map is used as the token. Later, we could unregister it using finalizer.unregister(map).

Example of WeakRef + Finalizer Combination

I pondered what could be built using the combination of WeakRef and Finalizer. While the TC39 proposal document has examples, and searching might yield good ones, I wanted to think of one myself. After considerable thought, I came up with the Weak Linked List. As the name suggests, it's a linked list that holds the data it stores via weak references. It's similar to WeakMap, but the weak reference is on the data, not the key.

Using finalizers here can make it more sophisticated. When specific data is deleted due to garbage collection, the finalizer can be used to remove the item holding the now-gone data from the list and repair the linked list to prevent breaks. It maintains only items with valid data. Let's look at the code. The linked list implementation is slightly simplified for convenience.

const weakLinkedList = ()=>{
  const head = { // Sentinel node
    next: null
  };

  let tail = head;

  // (2) Finalizer callback to clean up the list when data is GC'd
  const finalizer = new FinalizationRegistry(({prevItem, removedItem})=>{
    console.log('Data garbage collected, removing item:', removedItem);
    // Link previous item to the item after the removed one
    prevItem.next = removedItem.next;

    // If the removed item was the tail, update tail pointer
    if (removedItem === tail) {
      tail = prevItem;
    }
  });

  const add = (newData)=>{
    // Create a new list item holding a weak reference to the data
    const item = {
      next: null,
      data: new WeakRef(newData) // Store data weakly
    };

    // Append the new item to the list
    tail.next = item;

    // (1) Register the actual data object (newData) with the finalizer
    finalizer.register(newData, { // When newData is GC'd...
      prevItem: tail,       // ...pass the previous item...
      removedItem: item     // ...and the item being removed to the callback.
    }, item); // Use the item itself as the unregistration token (could also use head or other object)

    // Update the tail pointer
    tail = item;
  };

  return {
    head,
    add
  };
};

// Example Usage:
const list = weakLinkedList();
let obj1 = { id: 1, payload: new Array(5000).join('a') };
let obj2 = { id: 2, payload: new Array(5000).join('b') };

list.add(obj1);
list.add(obj2);

console.log('List head:', list.head);
console.log('Item 1 data (deref):', list.head.next.data.deref());
console.log('Item 2 data (deref):', list.head.next.next.data.deref());

// Remove strong reference to obj1, making it eligible for GC
obj1 = null;

// At some point later (after GC runs)...
// The finalizer callback will trigger, removing the item for obj1 from the list.
// You might need to trigger GC manually in some environments (like Node.js) to see this effect quickly.
// setTimeout(() => {
//   console.log('Checking list after potential GC...');
//   console.log('List head:', list.head);
//   console.log('Next item data (should be obj2):', list.head.next?.data.deref());
// }, 10000); // Wait for GC

In (1), as data is added to the linked list, we register the actual data object (newData) with the finalizer. We create an object containing the previous item (prevItem) and the item whose data might disappear (removedItem) to pass to the finalizer callback. The list item itself (item) is used as the unregistration token here (though head was mentioned in the original text, using item allows specific unregistration if needed). In the callback (2), it receives these two items. To remove the item with the disappeared data, it connects the next pointers. If the removed item was the tail, the tail pointer is updated to the previous item to ensure add continues to work correctly.

When the data held by an item somewhere in the linked list is garbage collected, the finalizer callback ensures that the item holding the now-empty data reference is removed, and the list is re-linked. Although a singly linked list was implemented, a doubly linked list could certainly be created as well. Testing the example showed it working correctly. However, it exhibited slightly different behavior compared to the simpler WeakRef example, enduring garbage collection a bit longer. This is likely due to differences in engine internals and how GC interacts with more complex object graphs and finalizers.

I hope the WeakRef and Finalizer combination gets used more widely, and use cases are shared so they can be utilized in applications in more diverse ways.

Promise.any: Resolves with the First Success

A new method for handling multiple promises has been added: Promise.any(). Unlike Promise.all(), which resolves only when all given promises resolve, Promise.any() resolves as soon as any one of the promises resolves. If all() is like an AND condition, any() is like an OR condition.

const playerA = new Promise((resolve) => {
   setTimeout(() => resolve('Player A finishes first'), Math.floor(Math.random() * 10));
});

const playerB = new Promise((resolve) => {
   setTimeout(() => resolve('Player B finishes first'), Math.floor(Math.random() * 10));
});


(async () => {
    try {
      console.log(await Promise.any([playerA, playerB]));
    } catch (error) {
      // This block executes only if ALL promises reject.
      console.error('All players failed:', error);
    }
})();

The result will be the message from either Player A or Player B, whichever finishes first.

Since Promise.any() resolves if at least one promise resolves, it only rejects if all the provided promises reject. It waits until the last promise rejects before determining the outcome as rejection. The rejection value is an AggregateError, containing all the rejection reasons.

It's still unclear how this might be typically used in situations involving server API calls and UI interactions.

Finally, String Has Built-in replaceAll

String has a replace method for changing parts of a string. However, this method had a drawback: it only replaces the first occurrence it encounters.

const message = 'What will be, will be';

message.replace('will', 'it'); // 'What it be, will be';

Yes, most of you probably know this. The suffering over the years was immense. So, what did we do? We used the split-join trick or regular expressions.

const message = 'What will be, will be';

message.split('will').join('it');  // 'What it be, it be';
message.replace(/will/g, 'it');  // 'What it be, it be'; // Using the global flag 'g'

This was often a point of confusion for developers familiar with other languages when they started using JavaScript. "No, there's no replaceAll," we'd answer, feeling slightly embarrassed for some reason, even though JavaScript and I are not one entity.

Now, we can confidently say, "Use replaceAll!"

message.replaceAll('will', 'it');  // 'What it be, it be';

It's a small thing, but it took a long time to get this small thing. That makes it even more noticeable.

Miscellaneous

Let's briefly summarize the rest.

Logical assignment operators have been added, expanding the types of assignment operators.

a &&= b // equivalent to a = a && b
a ||= b // equivalent to a = a || b
a ??= b // equivalent to a = a ?? b (nullish coalescing assignment)

These three logical assignment operators are now available. While they can be used in boolean logic, they are more likely to be frequently used for assigning a new value based on the presence or absence of an existing value. a &&= b could be used similarly to:

if (a) {
   a = b
}

And a ||= b for setting default values (if a is falsy), or a ??= b for setting defaults only if a is null or undefined.

Numeric separators have been introduced. To make numbers easier to read in code, you can now use underscores as separators within numeric literals. Commas might have been nicer, but apparently caused parsing ambiguities.

const separatedNumber = 1_000_000_000; // Easier to read than 1000000000

// Also works for non-decimal literals
const binary = 0b1010_1010;
const hex = 0xFF_EC_DE;

Finally, the specification for Array.prototype.sort has become more precise. Previously, sort was largely implementation-defined, providing only a basic spec and leaving the rest to browsers. This led to different sorting results across browsers in some cases (specifically for unstable sorts). The spec has now been refined to require a stable sort, reducing the potential for cross-browser inconsistencies.

In Conclusion

So, that was a belated summary of ECMAScript 2021. Personally, WeakRefs and Finalizers were the most interesting features. I'm happily pondering how to best utilize them. When will this darn COVID be garbage collected? I wish it would disappear quickly.

♥ Support writer ♥
with kakaopay

Creative Commons LicenseThis work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

shiren • © 2025Sungho Kim