Callbacks and References: Allowing the Garbage to get Collected
Tags: javascript, garbagecollection, webdev, frontend
Published: 2024-02-07 13:05:15

---
title: Callbacks and References: Allowing the Garbage to get Collected
published: true
description: A short explanation with an example of how to prevent event callbacks from keeping objects in memory even when there are no meaningful references to them anymore.
tags: JavaScript, garbagecollection, webdev, frontend
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4ju11unduzmlskcxt9ci.jpg
# Use a ratio of 100:42 for best results.
# published_at: 2024-02-07 12:26 +0000
---

JavaScript being a garbage-collected language, we don't usually have to concern ourselves with questions of allocating and releasing objects. But occasionally, specially when dealing with callbacks, it is easy to keep objects alive indefinitely, even if there are no longer any meaningful references to this.

There's several tools the language provides us to deal with these situations:

* `WeakRef` to store a single weak reference to an object
* `WeakMap` to associate values with objects only as long as they exist
* `WeakSet` to remember objects as long as they exist
* `FinalizationRegistry` to do something when an object gets collected

Depending on the situation, one may need one or another of these features, but the case I want to describe today will make use of the first and the last.

A common case is for objects to care about certain external state changes for as long as they exist. For example, a custom element might want to listen for `"scroll"` events on the `window` object. But naively adding an event listener to `window` means to keep a reference to the object. If these custom elements are short-lived but many in numbers, then they will accumulate in memory, and the additional event listeners will also pile up and waste processing power.

Here's a simple example of something like this:

```js
class MyElement extends HTMLElement {
   constructor() {
      super()
      window.addEventListener("scroll", event => {
         this.handleScroll()
      })
   }

   handleScroll() {
      this.classList.toggle("top", window.scrollY == 0)
   }
}
```

What we want is to remove the event listener by the time the object gets garbage-collected. To achieve this, we can make use of two features:

Firstly, replacing the strong reference to `this` in the event listener with a `WeakRef` will prevent the event listener from keeping the object alive if no other references to it exist. Once the object has been collected, the `deref()` method will just return undefined.

```js
const ref = new WeakRef(this)
window.addEventListener("scroll", event => {
   ref.deref()?.handleScroll()
})
```

This will allow the object to be garbage-collected, but will keep the event listener attached, meaning it will still fire on every scroll event, fail to `deref` the reference and therefore do nothing.

An easy way to clean up event listeners is to combine `AbortController` with `FinalizationRegistry`.

The former lets us pass a signal to an event that will remove the event, while the latter allows us to run some code when certain objects get collected.

The interface for this is relatively basic: We create a new `FinalizationRegistry` and pass it a callback. Then we register an object A and an associated (different) object B. When A gets garbage-collected, it obviously can't be passed to the callback, so instead, the callback is passed B.

```js
const abortRegistry =
   new FinalizationRegistry(c => c.abort())
```

This `abortRegistry` now allows us to register an object and an associated `AbortController`, and will call `abort()` on the controller whenever the object gets collected.

Now we just need to register our object on creation, and pass the controller's signal to the event listener.

Here's the complete code:

```js
const abortRegistry =
   new FinalizationRegistry(c => c.abort())

class MyElement extends HTMLElement {
   constructor() {
      super()
      const ref =
         new WeakRef(this)
      const controller =
         new AbortController()
      abortRegistry.register(this, controller)

      window.addEventListener("scroll", event => {
         ref.deref()?.handleScroll()
      }, { signal: controller.signal })
   }

   handleScroll() {
      this.classList.toggle("top", window.scrollY == 0)
   }
}
```

-----

## Comparing to lifecycle hooks

An easy point of criticism against the example above is that, whenever possible, resource cleanup should be achieved via the custom element API's lifecycle hooks `connectedCallback()` and `disconnectedCallback()`.

Two reasons why this may not be a viable alternative are that

1. This kind of cleanup might be necessary for objects that aren't DOM Elements and therefore cannot rely on their DOM connection for cleanup.

2. There may be cases where it is necessary to continue sending signals to a custom element even while it is disconnected from the DOM, usually because it might be inserted again at a later point in time.

In either case, the only option for cleanup is keep the object set up until it actually becomes inaccessible and therefore couldn't possibly be needed anymore.

-----

And that's it 😁

Did you learn something new? Was this old news to you? Let me know with a comment, and feel free to share how you handle cases like these in practice 👍

-----

Cover Image: [Jilbert Ebrahimi](https://unsplash.com/photos/black-plastic-garbage-bin-with-wheels-beside-wall-b0p818k8Ok8) (Unsplash License)