Memory Management in JavaScript
An overview
Memory management in JavaScript is handled automatically by a JavaScript engine, most popular engine being the v8 engine, developed by Google, used in Node.js. and Chromium-based browsers (Like Chrome, Edge etc.)
The engine uses 2 key memory structures to manage memory: the stack and the heap.
Memory Allocation
When an process starts (like starting a node server), the engine allocates an initial amount of memory to the stack and the heap.
On a node server, by default, the stack is typically allocated 1MB to 2MB while the heap is allocated around 1GB to 2GB. The allocation varies and can be adjusted based on system and engine settings e.g. size of the RAM.
--stack-size
for stack memory and --max-old-space-size
for heap memory)Memory allocation occurs whenever variables are declared, objects are created, functions are called, or closures are formed.
The Stack Memory
The stack is a simple last-in, first-out (LIFO) data structure, which is fast and efficient for storing small amounts of data.
The stack stores:
Primitive data structures such as strings, numbers, booleans, undefined, null
Function call frames, including local variables and arguments
References to complex objects.
As the code is executed, variables and references are pushed onto the stack, and once the execution is complete variables are popped off the stack, freeing the memory.
That stack allocation is generally faster than heap allocation due to the LIFO structure of the stack. That's why it is preferred for smaller, short-lived variables.
The Heap Memory
The heap is a larger, more dynamic region of memory that can grow and shrink as needed. It stores complex and larger data structures such as arrays, objects and functions. These objects are allocated on the heap and their references stored on the stack.
When code execution is done the data structures stored in the heap are not cleared immediately like the stack, instead, the memory is cleared by a garbage collector which is periodically run by the JS engine.
The Garbage Collector
The engine periodically runs a garbage collector that identifies and frees up memory that is no longer in use, specifically focusing on objects in the heap that are no longer referenced by any part of the program.
Key techniques for garbage collection:
Mark-and-Sweep algorithm.
Mark phase: The GC starts from the root objects (global variables, the call stack, event listeners), to the child nodes through references marking any object it reaches as "alive"
Sweep phase: After the marking phase, any object that was unreachable, that is, not marked as "alive" is considered "garbage" and is "swept" (cleared), freeing up the memory. The memory space occupied by these unreferenced objects is reclaimed and made available for future allocations.
Generational garbage collection. With this technique, a heap is split into 2 generations, a young one and an old one. The idea is that most objects are short-lived, so frequent garbage collection runs on the young generation, quickly reclaiming memory. The old generation is collected less frequently, focusing on long-lived objects.
Young Generation: Short-lived objects are allocated here and are quickly garbage collected.
Old Generation: Objects that survive several garbage collection cycles are promoted to the old generation.
Reference counting. With this technique, each object has a reference count, which tracks how many references point to it. When an object’s reference count drops to zero (meaning no references point to it), the memory for that object can be freed. [This technique is not used as often in modern JS engines]
How often is the garbage collector run?
Automatic and adaptive: The engine decides when to run the garbage collector based on the application's memory usage patterns, the size of the heap, and the rate of memory allocation. The GC will run more frequently when memory usage is high or when the application is allocating and deallocating many objects quickly.
Low Memory: If the system is running low on memory, the garbage collector may run more frequently in an attempt to free up space and prevent the application from crashing.
Idle Time: Some engines optimize garbage collection by performing it during periods of low activity or idle time, especially in environments like web browsers where user interaction may pause temporarily.
Cons of the GC running frequently:
Performance trade-offs: The GC running can cause pauses in the application's execution, leading to slower response times or reduced throughput.
Heap Fragmentation: Frequent garbage collection can also lead to heap fragmentation, where free memory is scattered in small chunks across the heap, making it harder for the GC to allocate large contiguous blocks of memory for new objects. [This isn't a major concern eith modern GC techniques]
Can we do anything to manage how frequently Garbade Collector is run?
Optimize Code: avoid unnecessary object creation, manage the lifecycle of objects carefully, and minimize the use of global variables.
Tuning GC Settings: Fine-tune GC settings to balance the frequency of garbage collection with performance needs. This might involve adjusting heap size limits or experimenting with different GC options in the V8 engine.
Monitoring and Scaling: Use monitoring tools to track memory usage and GC activity. If you notice excessive GC activity affecting performance, consider scaling up the memory available to the application or optimizing the workload.
When a Node.js application is terminated (either gracefully or forcefully), the operating system reclaims all memory that was allocated to the process. This includes both the heap and stack.
Clearing Memory Manually
Memory can also be cleared manually, so that the garbage collector can collect
Setting References to Null: Explicitly setting objects to
null
when they are no longer needed, makes them eligible for garbage collection.Removing Event Listeners: Cleaning up event listeners that are no longer needed to prevent memory leaks.
let obj = { name: "Bob" }; obj = null; // The object is now unreachable and can be garbage collected
Code Demo
Consider this piece of code. Can you tell what's happening here in terms of memory allocation?
const func = () => {
const name = "David"
const obj = { a: 1, b:1 }
console.log(name, obj)
}
func()
In the stack:
The function
func
has its own stack frame, which includes local variables likeobj
andname
. When the function is called, a stack frame is created and pushed onto the call stack.Once
func
completes execution, its stack frame is popped off the stack, and the memory for local variables (e.g., the reference toobj
) is automatically cleared from the stack.
In the heap:
The object
{ a: 1, b: 1 }
thatobj
references is allocated on the heap because it’s a non-primitive data structure (an object).When
func()
completes, the reference toobj
is no longer in scope, meaning there are no longer any references to the object on the heap.
Garbage Collection (Mark-and-Sweep):
After the function exits, the reference to
obj
is lost, making the object on the heap unreachable.During the next garbage collection cycle, the garbage collector will identify that the object is no longer reachable from any root nodes (e.g., global objects, active function contexts).
The object
obj
will then be marked as garbage and will be eligible for collection in the next GC cycle. The memory occupied by the object will be reclaimed and made available for future allocations.If the application is under memory pressure or if more objects are being allocated rapidly, the GC might run sooner. In less busy applications, it might run less frequently.
Memory Leaks
Memory leaks happen when a process fails to release memory that is no longer needed, which over time, can cause the application to to be slow, unresponsive or even crash. Memory leaks can cause stack and heap overflows.
How do memory leaks happen?
Unreleased references - if an object is no longer needed but still has active references pointing to it, the GC cannot reclaim the memory occupied by that object. This can happen with closures, event listeners, timers, reference cycles (when two or more objects reference each other in a way that prevents them from being garbage collected).
Unintended Global Variables - these variables persist throughout the lifetime of the application and are not garbage collected until the application exits.
Detached DOM Elements - in web applications, if DOM elements are removed from the document but are still referenced in JavaScript, the memory associated with those elements will not be freed
How to detect memory leaks (common signs):
Gradual Increase in Memory Usage over time, even when the workload remains constant.
Performance Degradation: As memory is consumed, the application may slow down due to increased garbage collection frequency or the inability to allocate new memory efficiently.
Application Crashes: In severe cases, the application may crash with out-of-memory errors, especially in environments with limited resources.
Tools for Detecting Memory Leaks:
The DevTools performance tab has a profiler to help identify functions creating a large number of objects, or functions that are being executed more frequently than expected, potentially leading to memory leaks.
The DevTools memory tab has features that take snapshots of heap memory, you can use that to compare different sessions. For node applications the heapdump module comes in handy.
Running the Node.js application with the
--inspect
flag to enable debugging and connect Chrome DevTools to inspect memory usage.Using monitoring tools like clinic.js, google lighthouse, node-memwatch, or custom logging to track memory usage over time
The end 🫶
Subscribe to my newsletter
Read articles from Shalon N. Ngigi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by