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.
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.
- Part 2: JavaScript's Memory Management: Heap And Garbage Collection Explained
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.
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:
Stack | Heap |
Primitive values and references | Objects and functions |
Size is known at compile time | Size is known at run time |
Allocates a fixed amount of memory | No limit per object |
Let's have a look at a few code examples. In the captions I mention what is being allocated:
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.
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.
In this picture, we can observe how different values are stored. Note how
person
and newPerson
both point to the same object.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.
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.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;
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
.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.
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.
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.
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.
Storing data in global variables is probably the most common type of memory leak.
In the browser, for instance, if you use
var
instead of const
or let
—or leave out the keyword altogether—the engine will attach the variable to the window
object.The same will happen to functions that are defined with the
function
keyword.All three variables,
user
, secondUser
, and getUser
, will be attached to the window
object.This only applies to variables and functions that are defined in the global scope. If you want to learn more about this, check out this article explaining the JavaScript scope.
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.
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.
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);
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)); }); }
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:
- Memory Management - MDN Web Docs
A great resource to read up on this topic again.
- This article goes more into the details of how the V8 engine works. I found this very interesting.
Other articles you might be interested in:
- In the first part of this blog series, I explain why you can do things concurrently in the browser, even though JavaScript is a single-threaded language.
- My 9 favorite topics of "The Pragmatic Programmer"
Reading is a great way of improving your programming skills. In this article, I share my key takeaways from my favorite programming book.
- This article is closely related to this one as it describes what you can do about your Node.js app running out of memory.
If you want more articles like this one, leave me a message and make sure you are subscribed to my email newsletter.