JavaScript's Memory Management Explained

JavaScript Memory Management Explained - Cover

Most of the time, you can probably get by fine not knowing anything about memory management as a JavaScript developer. Afterall, the JavaScript engine handles this for you.

At one point or another, though, you'll encounter problems, like memory leaks, that you can only solve if you know how memory allocation works.

In this article, I'll introduce you to how memory allocation and garbage collection works and how you can avoid some common memory leaks.

How does JavaScript work in the browser?

This article is the second part of my post series, where I explain how JavaScript works in the browser. To get my latest articles to your inbox, subscribe to my newsletter.

Memory life cycle

In JavaScript, when we create variables, functions, or anything you can think of, the JS engine allocates memory for this and releases it once it's not needed anymore.

Allocating memory is the process of reserving space in memory, while releasing memory frees up space, ready to be used for another purpose.

Every time we assign a variable or create a function, the memory for that always goes through the same following stages:

Memory life cycle overview

  • Allocate memory

    JavaScript takes care of this for us: It allocates the memory that we will need for the object we created.

  • Use memory

    Using memory is something we do explicitly in our code: Reading and writing to memory is nothing else than reading or writing from or to a variable.

  • Release memory

    This step is handled as well by the JavaScript engine. Once the allocated memory is released, it can be used for a new purpose.

"Objects" in the context of memory management doesn't only include JS objects but also functions and function scopes.

The memory heap and stack

We now know that for everything we define in JavaScript, the engine allocates memory and frees it up once we don't need it anymore.

The next question that came to my mind was: Where is this going to be stored?

JavaScript engines have two places where they can store data: The memory heap and stack.

Heaps and stacks are two data structures that the engine uses for different purposes.

Stack: Static memory allocation

You might know the stack from the first part of this series on the call stack and event loop, where I focused on how it's used to keep track of the functions that the JS interpreter needs to call.

Memory stack Examples

All the values get stored in the stack since they all contain primitive values.

A stack is a data structure that JavaScript uses to store static data. Static data is data where the engine knows the size at compile time. In JavaScript, this includes primitive values (strings, numbers, booleans, undefined, and null) and references, which point to objects and functions.

Since the engine knows that the size won't change, it will allocate a fixed amount of memory for each value.

The process of allocating memory right before execution is known as static memory allocation.

Because the engine allocates a fixed amount of memory for these values, there is a limit to how large primitive values can be.

The limits of these values and the entire stack vary depending on the browser.

Heap: Dynamic memory allocation

The heap is a different space for storing data where JavaScript stores objects and functions.

Unlike the stack, the engine doesn't allocate a fixed amount of memory for these objects. Instead, more space will be allocated as needed.

Allocating memory this way is also called dynamic memory allocation.

To get an overview, here are the features of the two storages compared side by side:

StackHeap
Primitive values and referencesObjects and functions
Size is known at compile timeSize is known at run time
Allocates a fixed amount of memoryNo limit per object

Examples

Let's have a look at a few code examples. In the captions I mention what is being allocated:

const person = {
  name: 'John',
  age: 24,
};

JS allocates memory for this object in the heap. The actual values are still primitive, which is why they are stored in the stack.

const hobbies = ['hiking', 'reading'];

Arrays are objects as well, which is why they are stored in the heap.

let name = 'John'; // allocates memory for a string
const age = 24; // allocates memory for a number

name = 'John Doe'; // allocates memory for a new string
const firstName = name.slice(0,4); // allocates memory for a new string

Primitive values are immutable, which means that instead of changing the original value, JavaScript creates a new one.

References in JavaScript

All variables first point to the stack. In case it's a non-primitive value, the stack contains a reference to the object in the heap.

The memory of the heap is not ordered in any particular way, which is why we need to keep a reference to it in the stack. You can think of references as addresses and the objects in the heap as houses that these addresses belong to.

Remember that JavaScript stores objects and functions in the heap. Primitive values and references are stored in the stack.

Stack heap pointers explained

In this picture, we can observe how different values are stored. Note how person and newPerson both point to the same object.

Examples

const person = {
  name: 'John',
  age: 24,
};

This creates a new object in the heap and a reference to it in the stack.

References are a core concept of how JavaScript works. Going more into detail here would be out of the scope of this article, but if you want to learn more about it, let me know in the comments and subscribe to my newsletter.

Garbage collection

We now know how JavaScript allocates memory for all kinds of objects, but if we remember the memory lifecycle, there's one last step missing: releasing memory.

Just like memory allocation, the JavaScript engine handles this step for us as well. More specifically, the garbage collector takes care of this.

Once the JavaScript engine recognizes that a given variable or function is not needed anymore, it releases the memory it occupied.

The main issue with this is that whether or not some memory is still needed is an undecidable problem, which means that there can't be an algorithm that's able to collect all the memory that's not needed anymore in the exact moment it becomes obsolete.

Some algorithms offer a good approximation to the problem. I'll discuss the most used ones in this section: The reference-counting garbage collection and the mark and sweep algorithm.

Reference-counting garbage collection

This one is the easiest approximation. It collects the objects that have no references pointing to them.

Let's have a look at the following example. The lines represent references.

Note how in the last frame only hobbies stays in the heap since it's the object one that has a reference in the end.

Cycles

The problem with this algorithm is that it doesn't consider cyclic references. This happens when one or more objects reference each other, but they can't be accessed through code anymore.

let son = {
  name: 'John',
};

let dad = {
  name: 'Johnson',
}

son.dad = dad;
dad.son = son;

son = null;
dad = null;

Reference cycle illustrated

Because son and dad objects reference each other, the algorithm won't release the allocated memory. There's no way for us to access the two objects anymore.

Setting them to null won't make the reference-counting algorithm recognize that they can't be used anymore because both of them have incoming references.

Mark-and-sweep algorithm

The mark-and-sweep algorithm has a solution to cyclic dependencies. Instead of simply counting the references to a given object, it detects if they are reachable from the root object.

The root in the browser is the window object, while in NodeJS this is global.

Mark-and-sweep algorithm illustrated

The algorithm marks the objects that aren't reachable as garbage, and sweeps (collects) them afterward. Root objects will never be collected.

This way, cyclic dependencies are not a problem anymore. In the example from before, neither the dad nor the son object can be reached from the root. Thus, both of them will be marked as garbage and collected.

Since 2012, this algorithm is implemented in all modern browsers. Improvements have only been made to performance and implementation, but not to the algorithm's core idea itself.

Trade-offs

Automatic garbage collection allows us to focus on building applications instead of losing time with memory management. However, there are some tradeoffs that we need to be aware of.

Memory usage

Given that the algorithms can't know when exactly memory won't be needed anymore, JavaScript applications may use more memory than they actually need.

Even though objects are marked as garbage, it's up to the garbage collector to decide when and if the allocated memory will be collected.

If you need your application to be as memory efficient as possible, you're better off with a lower-level language. But keep in mind that this comes with its own set of trade-offs.

Performance

The algorithms that collect garbage for us usually run periodically to clean unused objects.

The issue with this is that we, the developers, don't know when exactly this will happen. Collecting a lot of garbage or collecting garbage frequently might impact performance since it needs a certain amount of computation power to do so.

However, the impact usually goes unnoticeable to the user or the developer.

Memory leaks

Armed with all this knowledge about memory management, let's have a look at the most common memory leaks.

You will see that these can be easily avoided if one understands what is going on behind the scenes.

Global variables

Storing data in global variables is probably the most common type of memory leak.

In JavaScript for the browser, if you leave out the var, const, or let, the variable will be attached to the window object.

users = getUsers();

Avoid this by running your code in strict mode.

Apart from adding variables accidentally to the root, there are many cases in which you might do this on purpose.

You can certainly make use of global variables, but make sure you free space up once you don't need the data anymore.

To release memory, assign the global variable to null.

window.users = null;

I want to make this article as easy to understand as possible. If you have any open questions, please send me an email or leave a comment. I will try to help you and improve the article with your feedback.

Forgotten timers and callbacks

Forgetting about timers and callbacks can make the memory usage of your application go up. Especially in Single Page Applications (SPAs), you have to be careful when adding event listeners and callbacks dynamically.

Forgotten timers

const object = {};
const intervalId = setInterval(function() {
  // everything used in here can't be collected
  // until the interval is cleared
  doSomething(object);
}, 2000);

The code above runs the function every 2 seconds. If you have code like this in your project, you might not need this to run all the time.

The objects referenced in the interval won't be garbage collected as long as the interval isn't canceled.

Make sure to clear the interval once it's not needed anymore.

clearInterval(intervalId);

This is especially important in SPAs. Even when navigating away from the page where this interval is needed, it will still run in the background.

Forgotten callbacks

Let's say you add an onclick listener to a button, which later on gets removed.

Old browsers weren't able to collect the listener, but nowadays, this isn't a problem anymore.

Still, it's a good idea to remove event listeners once you don't need them anymore:

const element = document.getElementById('button');
const onClick = () => alert('hi');

element.addEventListener('click', onClick);

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

Out of DOM reference

This memory leak is similar to the previous ones: It occurs when storing DOM elements in JavaScript.

const elements = [];
const element = document.getElementById('button');
elements.push(element);

function removeAllElements() {
  elements.forEach((item) => {
    document.body.removeChild(document.getElementById(item.id))
  });
}

When you remove any of those elements, you'll probably want to make sure to remove this element from the array as well.

Otherwise, these DOM elements can't be collected.

const elements = [];
const element = document.getElementById('button');
elements.push(element);

function removeAllElements() {
  elements.forEach((item, index) => {
    document.body.removeChild(document.getElementById(item.id));
    elements.splice(index, 1);  });
}

Removing the element from the array keeps it in sync with the DOM.

Since every DOM element keeps a reference to its parent node as well, you'll prevent the garbage collector from collecting the element's parent and children.

Conclusion

In this article, I summarized the core concepts of memory management in JavaScript.

Writing this article helped me clear up some concepts that I didn't understand completely, and I hope this will serve as a good overview of how memory management works in JavaScript.

I've learned this from some other great articles I want to mention here as well:

Other articles you might be interested in:

If you want more articles like this one, leave me a message and make sure you are subscribed to my email newsletter.