Memory leaks are one of the most common issues in JavaScript applications. Let’s explore why and in which cases memory leaks can occur.

What is the memory leaking? #

It’s a state when the memory that was allocated for data continues to stay in program memory after finishing part of the code in which it was used, as a result, the allocated memory stays in memory forever and reduces the program performance.

Why memory leaks occur? #

Garbage Collector (GC) is a program that removes the allocated memory for data when data is no longer needed, if data hasn’t any reference in the code, it means it’s not used in the program so it will be deleted by GC.

Memory Leak Examples #

To illustrate memory leaks in my examples I will use the following code that logs usage memory in the app and prints it to the console.

const process = require('node:process');

const byteToMb = b => b / Math.pow(1000, 2);
const processStartTime = Date.now();
const memoryStates = [];

const logUsageMemory = () => {
    const memoryUsage = process.memoryUsage();
    memoryStates.push({
        rss: byteToMb(memoryUsage.rss), // resident set size, the amount of space for the process
        heapTotal: byteToMb(memoryUsage.heapTotal),
        heapUsed: byteToMb(memoryUsage.heapUsed),
        external: byteToMb(memoryUsage.external),
        arrayBuffers: byteToMb(memoryUsage.arrayBuffers),
        processTime: Date.now() - processStartTime,
    });
    console.clear();
    console.table(memoryStates);
}

Unused variable and reference #

Mostly JavaScript Garbage Collector can release an allocated memory for unused variables and references. Still, GC can’t do it when the variable’s reference is implicitly used somewhere. In this case, each defined variable should be properly released. For instance, let’s run the following code with an unused variable:

logUsageMemory();
const bigUnusedArray = new Array(10000000);
setInterval(logUsageMemory, 1000);

Let’s see the memory usage:

(index)rssheapTotalheapUsedexternalprocessTime
039.8295046.1445.4387360.425280
1120.32409686.425685.733080.4322071014
2120.45516886.425685.759720.4322072015
3120.71731286.425685.7939440.4322073017
4120.89753686.425685.8365760.4322074017
5121.04499286.425685.886960.4322075018
6121.11052886.425685.9462160.4322076020
742.1560327.7332485.7106880.4322077021
842.2543367.7332485.785640.4322078021
942.1232646.6846724.8083040.4298459023

In this case, at 7 seconds process running the GC released the memory for unused variables.

Unused Circular References #

The oldest JS Garbage Collectors (pre-2012) used the reference-counting algorithm, which counts the number of references on a variable to determine remove allocated memory to data or not, the disadvantage of this algorithm is that it can’t resolve the circular references between data, to resolve this issue this algorithm was replace to the mark-and-sweep algorithm. Memory in the next code will be released by GC:

logUsageMemory();
const obj1 = { ref: null, arr: new Array(10000000) };
const obj2 = { ref: obj1, arr: new Array(10000000) };
obj1.ref = obj2;
setInterval(logUsageMemory, 1000);

Memory usage (output from logUsageMemory):

(index)rssheapTotalheapUsedexternalprocessTime
037.9125766.1445.4387360.425280
1199.688192167.493632165.4472560.4322071058
2200.015872167.493632165.4741120.4322072059
3200.37632167.77216165.770760.4322073060
4200.441856167.77216165.8135040.4322074061
5200.589312167.77216165.8639840.4322075063
6200.654848167.77216165.9232720.4322076063
7200.720384167.77216165.9898720.4322077065
8200.802304167.77216166.0646880.4322078067
940.4029446.6846724.8086320.4298459068
1040.4848646.6846724.9665840.42984510069

This is especially true if the variable was defined in a global scope or in long-living functions. Why did I mention functions? Because in this case, the garbage collector will release the memory that was allocated within a function when the function finishes executing. However, I provided the next cases where memory leaks can still occur within a function.

Global Variables #

Worth mentioning, that GC will not release memory for unused properties of objects due to its use of the mark-and-sweep algorithm.

const obj1 = { type: 1,  payload: new Array(10000000) };
setInterval(() => {
    console.log(obj1.type); // using one property from the object, GC won't release memory that was allocated for the payload property
    logUsageMemory();
}, 1000);

That's why, if you set data to global scope, it always will be in global scope, because the global scope is a JS object. In NodeJS global scope can be accessed from the variable global, and the variable window in browsers.

global.userPayload = { type: 1,  payload: new Array(10000000) };

To release the memory property should be removed from the object manually.

References inside a closure #

In a similar case with references inside a closure. When we access variables declared at a higher level inside a closure, it creates a reference on the variable, and we need to properly remove this reference or variable.

function createClosure() {
   const data = { /* a large object */ };
   return () => {
     console.log(data); // reference created
   }
};
const printData = createClosure();
// … using the printData closure somewhere

Reference will be automatically removed by the garbage collector if it doesn’t have any other references to itself out of scope, or to remove the reference we can assign a null to variable printData. One of the most common cases where this error occurs is in listeners that, reviewed below

Incorrect removing the event listener The common case of memory leaking in listeners is when the listener was not properly removed, and closure keeps references to internal or external objects.

const { EventEmitter } = require('node:events');
const eventEmitter = new EventEmitter();
const setupListener = () => {
    const obj1 = new Array(10000000);
    const processExited = () => console.log(obj1);
    eventEmitter.on('someEvent', processExited);
    // We need to call the removeListener when done, otherwise, variable obj1 will stay in memory
    // eventEmitter.removeListener('someEvent', processExited);
}
setupListener();

Pay attention, even if you remove the emitter object (customEventEmitter in the example) without removing the listener via removeListener, the listener won’t be removed.

An example just with the front-end and registering listener via HTML element, even if the element will be removed, the listeners continue to stay in memory:

const data = { /* Large object */ };
const eventListener = () => { console.log(data); }; // the variable captured in a closure,
element.addEventListener("customEvent", eventListener);

// We need to call the removeEventListener when done,
// just removing “element” will not remove the listener
// customEventEmitter.removeEventListener(“customEvent”, eventListener);

Timers and Intervals #

The next obvious case of memory leaking is not removing the timers or intervals when work is done. Moreover, this kind of issue can be not only the reason for memory leaking but also the leaking of other resources, depending on your function.

const intervalId = setInterval(intervalListener, 1000);
// if you forget to call clearInterval(intervalId), function “intervalListener” keeps running

Unclosed File Handles #

The next case occurs typically when you open files but do not close them properly. Memory leaks with file streams happen because an open file stream holds resources in memory, such as file descriptors and buffers, and if you don't close the stream when you're done with it, these resources are not released.

const stream = fs.createReadStream(__filename, () => {});
stream.on('data', (chunk) => { /* Do something with the data */ });
// Close the file stream when done
// stream.on('end', () => stream.close() );

Unclosed Network Connections #

Memory leaks with unclosed network connections in Node.js can occur when you open network sockets or connections, such as HTTP requests, but don’t close them properly.

const http = require('node:http');
 const req = http.request({
    hostname: 'www.google.com',
    port: 80,
    method: 'GET',
}, (res) => {
    res.on('data', () => { /* do something with data */  });
    // Connection should be closed properly
    // res.on('end', () => req.end());
});

Conclusion #

Understanding and addressing memory leaks in JavaScript applications is crucial for maintaining their performance and resource efficiency. By recognizing the various scenarios that can lead to memory leaks and taking proactive steps to mitigate them, you can ensure your applications run smoothly and efficiently, providing a better user experience and avoiding potential issues down the line.

To prevent memory leaks in JavaScript applications, follow the practices that I discussed above:

  • Clear unused variables and references.
  • Handle circular references carefully.
  • Manually remove global object properties.
  • Manage references within closures effectively.
  • Remove unnecessary event listeners.
  • Clear timers and intervals when finished.
  • Close file streams properly.
  • Terminate unclosed network connections.