Memory leaks are a silent threat that gradually degrades performance, leads to crashes, and increases operational costs. Unlike obvious bugs, memory leaks are often subtle and difficult to spot until they start causing serious problems.
Increased memory usage drives up server costs and negatively impacts user experience. Understanding how memory leaks occur is the first step in addressing them.
Understanding Memory Leaks
A memory leak happens when your application allocates memory and then fails to release it after it’s no longer needed. Over time, these unreleased memory blocks accumulate, leading to progressively higher memory consumption.
This is especially problematic in long-running processes like web servers, where the leak can cause the application to consume more and more memory until it eventually crashes or slows down to a crawl.
Understanding Memory Usage in Node.js (V8)
Node.js (V8) handles several distinct types of memory. Each plays a critical role in how your application performs and utilizes resources.
Memory Type
Description
RSS (Resident Set Size)
Total memory allocated for the Node.js process, including all parts of the memory: code, stack, and heap.
Heap Total
Memory allocated for JavaScript objects. This is the total size of the allocated heap.
Heap Used
Memory actually used by the JavaScript objects. This shows how much of the heap is currently in use.
External
Memory used by C++ objects that are linked to JavaScript objects. This memory is managed outside the V8 heap.
Array Buffers
Memory allocated for ArrayBuffer objects, which are used to hold raw binary data.
RSS (Resident Set Size): The total memory allocated for the process.
RSS refers to the total memory footprint of a Node.js process. It includes all memory allocated for the process, including the heap, stack, and code segments.
This script logs the RSS memory usage every second. We can observe how the total memory footprint changes over time.
Heap Total: The amount of memory allocated for the JavaScript objects.
Heap Total represents the total amount of memory allocated for the JavaScript objects by the V8 engine (the JavaScript engine used by Node.js).
Allocating a large array increases the heap total. The logged heap total shows the memory allocated for JavaScript objects.
Heap Used: The amount of memory actually used by the objects.
Heap Used refers to the amount of memory that is currently being used by the JavaScript objects on the heap.
When we push objects into an array, we’re increasing the amount of memory used by the heap.
The heap used value will rise as more objects are added.
External: Memory used by C++ objects bound to JavaScript.
External memory refers to the memory used by C++ objects linked to JavaScript. These objects are created through bindings that let JavaScript interact with native code, allocating memory outside of the typical JavaScript heap.
This memory isn’t directly visible in JavaScript but still adds to the total memory used by the application.
The Buffer.alloc method allocates a 50MB buffer, which is tracked as external memory.
This example logs the external memory usage, which will reflect the buffer allocation.
Array Buffers: Memory allocated for ArrayBuffer objects.
Array Buffers are memory used for ArrayBuffer objects. These objects store fixed-length binary data in JavaScript.
ArrayBuffer is part of JavaScript’s typed array system, letting you work with binary data directly.
The memory for these buffers is tracked separately from regular JavaScript objects. They’re often used for handling raw data, like files or network protocols.
Here’s an example where I allocate a 50MB ArrayBuffer and then check the initial memory usage of my Node.js process.
Common Causes of Memory Leaks in JavaScript
Improperly Managed Variables
Variables that are not properly managed can cause memory leaks.
For instance, if you declare variables that are supposed to be temporary but forget to clean them up, they will continue to consume memory.
In the example above, data is added to a global object called cache. If this data isn’t removed when it’s no longer needed, it will keep using memory unnecessarily.
This is especially problematic if these variables are stored in a global scope, making them persist throughout the application’s lifecycle.
globalUserSessions is a global object used to store user session data. Because it’s in the global scope, it persists for the entire runtime of the application.
If sessions are not properly removed using removeUserSession, the data will remain in memory indefinitely, leading to a memory leak.
Persistent Global Objects
Global objects can hold onto memory longer than needed. Data in them can stay in memory after it’s no longer needed. This gradually increases memory usage.
Since config is globally accessible and never cleared, the memory it uses is retained for the entire runtime of the application. Here’s one way we can avoid the memory leak:
Instead of storing config in a global object, we store config locally within a function. This ensures that config is cleared after the function runs, freeing up memory for garbage collection.
Event Listeners Not Removed
Adding event listeners without removing them properly when they are no longer needed can lead to memory leaks.
Each event listener retains a reference to the function and any variables it uses, preventing the garbage collector from reclaiming that memory.
Over time, if you keep adding listeners without removing them, this will result in increased memory usage.
Here’s an example that demonstrates how event listeners can cause memory leaks if not properly removed:
A new event listener is added every second. However, these listeners are never removed, which causes them to accumulate in memory.
Each listener holds a reference to the listener function and any associated variables, preventing garbage collection and leading to increased memory usage over time.
To prevent this memory leak, you should remove event listeners when they are no longer needed.
Closures Capturing Variables
Closures in JavaScript can unintentionally hold onto variables longer than needed. When a closure captures a variable, it keeps a reference to that memory.
If the closure is used in a long-running process or isn’t properly terminated, the captured variables stay in memory, causing a leak.
To avoid leaks, ensure closures don’t unnecessarily capture large variables or end them when no longer needed.
Unmanaged Callbacks
In certain scenarios, unmanaged callbacks can cause memory issues if they hold onto variables or objects longer than necessary.
However, JavaScript’s garbage collector is generally effective at cleaning up memory once references are no longer needed.
In the example above:
Data Allocation: The fetchData function allocates a large array (data), which holds 1 million elements.
Callback Reference: The callback function handleData references this large array when it’s invoked by setTimeout after 1 second.
Despite the large allocation, JavaScript’s garbage collector ensures that memory is released when no longer needed.
There is no need to manually clear the references unless you are dealing with very complex scenarios where references are unintentionally retained.
Overly Complex (Not Recommended)
While this code manually clears references and explicitly triggers garbage collection, it introduces unnecessary complexity.
JavaScript’s garbage collector is typically sufficient for handling memory cleanup without these additional steps.
In most scenarios, such manual interventions are not only redundant but can also make the code harder to maintain.
Incorrect Use of bind()
Using bind() creates a new function with its this keyword set to a specific value. If you’re not careful, this can cause memory leaks.
Why Memory Leaks Happen with bind()
1. References are Kept: When you use bind(), the new function remembers the original function and this value. If you don’t remove the function when it’s no longer needed, it sticks around and uses memory.
2. Big Objects Stay in Memory: Bound functions can accidentally keep large objects in memory, even if you don’t need them anymore.
Circular References
Circular references happen when two objects refer to each other. This creates a loop that can confuse the garbage collector, preventing it from freeing up memory.
Even if you set obj to null, the memory might not be released because of the self-loop. Here’s how you can avoid Circular Reference.
Break the Loop: Make sure objects don’t refer back to each other when they are no longer needed. This helps the garbage collector clear them out.
By setting obj.reference to null, we break the circular reference. This allows the garbage collector to free up the memory when obj is no longer needed.
Use Weak References: Using WeakMap, WeakSet, or WeakRef allows the garbage collector to clean up memory even if there are references, as long as they are weak.
weakMap holds a weak reference to obj. This means that when obj is no longer used elsewhere, it can still be garbage collected even though it’s referenced in weakMap.
weakRef allows you to hold a weak reference to obj. If obj is set to null and there are no other references to it, it can be garbage collected, even though weakRef still exists.
Quick Note
WeakMap, WeakSet, and WeakRef are great for preventing memory leaks, but you might not need them all the time. They’re more for advanced use cases, like managing caches or big data.
If you’re working on typical web apps, you might not see them often, but it’s good to know they exist when you need them.
Profiling Memory Usage in Node.js
To find memory leaks, you need to profile your application to understand how memory is being used.
Here’s a Node.js application designed to simulate CPU-intensive tasks, I/O operations, and intentionally create a memory leak for testing purposes.
Next, we need to stress test our server. This script stress tests the server by sending 100 requests each to simulate CPU, I/O, and memory leaks.
It loops through the URLs and sends silent requests using curl, running them in the background to simulate a high load.
Here’s how our server responds to the stress test. Make sure the server is running before you start the test.
Analysing the Results
The profiling data will be saved in a file with a name like isolate-0xXXXXXXXXXXXX-v8.log.
To process the log and get a human-readable summary, run:
This will generate a processed-profile.txt file with the CPU profiling data, which includes details about where your application spent time and how it managed memory.
Open the processed-profile.txt file and look for areas where a significant amount of time or memory is being used.
Pay particular attention to:
High CPU usage functions: These are the bottlenecks in your code.
Memory-intensive functions: Functions that consume large amounts of memory might point to potential memory leaks, especially if they correspond to parts of your code that are supposed to release memory but don’t.
Event Loop and Garbage Collection (GC): Look for a high percentage of time spent in GC, as this might suggest that the application is struggling with memory management.
Memory leaks may be subtle, but addressing them is key to keeping your JavaScript applications efficient and reliable.
Here are engineering resources I've personally vetted and use. They focus on skills you'll actually need to build and scale real projects - the kind of experience that gets you hired or promoted.