DEV Community

Cover image for Minimize Heap Allocations in Node.js
Ifeanyi Dike for AppSignal

Posted on • Originally published at blog.appsignal.com

Minimize Heap Allocations in Node.js

Memory management has always been a source of huge concern in Computer Science. Each piece of software is assigned a small portion of a computer’s finite memory; this memory has to be well-managed (carefully allocated and deallocated).

With its efficient automatic garbage collection mechanism, Node.js tries to handle the tedious task of memory management and free up developers to work on other tasks.
While this is great, understanding the mechanism of memory management in V8 and Node.js is still important, especially when working on large applications.

This article explains how memory is allocated and deallocated in the heap. We'll also provide guidelines to help you minimize heap allocation and prevent memory leaks in Node.js.

Let's get going!

Heap Allocation in Node.js

JavaScript and Node.js abstract a lot of things for you and do most of the heavy lifting in the background.

On executing a program, variables and objects are stored in the stack or the heap. JavaScript code is stored in the Execution Context to be executed.

The Stack

When you declare a variable and assign a primitive data type, JavaScript assigns a fixed amount of memory at a memory address for it on the call stack. Primitive data types are immutable — their value cannot be changed. The variable may be reassigned a new value, but the existing value can not be altered.

This makes data allocation and deallocation easy in the stack, following a simple LIFO (Last In First Out) approach. Data types stored in the stack include:

  • undefined
  • string
  • number
  • boolean
  • bigint
  • null
  • references to objects stored in the heap

In JavaScript, primitives are assigned/passed by value and not by reference. If you assign a primitive type's value to another variable, the value is copied - not assigned a reference to the first variable (which is what happens for objects). You can see this in the example below.

let num1 = 5;
let num2 = num1;

num1 = 6;
console.log({ num1, num2 }); // { num1: 6, num2: 5 }
Enter fullscreen mode Exit fullscreen mode

When you log num1 and num2 to the console, the value of num1 is 6, whereas num2 still maintains the value of 5. When num1 is assigned to num2 the JavaScript engine creates a copy of its value and assigns it to num2, so the two variables have a different copy of a value. Changing one variable's value down the line has no effect on the other variable.

In some programming languages (e.g. C++ and Pascal), each type can be passed by value or reference, so the developer is at liberty to decide this, but not with JavaScript. In JavaScript, primitive types are passed by value, and objects are passed by reference. This also determines how they are stored in memory — primitive values are stored on the stack while objects are stored on the heap, with a reference to their location stored on the stack.

One important thing to note about variables stored in the stack is that their sizes must be known at compile time. This allows a program to allocate the right amount of memory in the stack. Because the JavaScript engine allocates a fixed amount of memory for primitive values, there is a limit to how large they can be.

The Heap

Memory allocation, as described above, is only possible with primitive variables. Dynamic variables or reference data types such as objects, functions, and arrays cannot be stored in the stack — they must be stored in the heap.

Unlike the stack, the heap does not follow the LIFO protocol. The sizes of functions and objects can only be determined at runtime, and so a program cannot allocate space for them in the stack. This requires dynamic memory allocation, where memory is stored in the heap and a pointer to that memory address is stored in the stack.

Consider the following example below.

const personObject = {
  id: 1,
  name: "Samuel",
  age: 30,
};

const personFunction = () => {
  return personObject.age;
};
Enter fullscreen mode Exit fullscreen mode

The variables personObject and personFunction are stored in the stack, but their value is a pointer to a memory address in the heap. personObject and personFunction are assigned memory in the heap and a pointer to that memory address is stored in the stack.

If you assign the value of an object to another, the reference to that object will be assigned to the first object, so they will both point to the same object in memory.

For instance:

let personObject = {
  id: 1,
  name: "Samuel",
  age: 30,
};

let newPersonObject = personObject; // this will point to the same heap memory as personObject

newPersonObject.name = "Alex";

console.log(personObject); // { id: 1, name: 'Alex', age: 30 }
console.log(newPersonObject); // { id: 1, name: 'Alex', age: 30 }
Enter fullscreen mode Exit fullscreen mode

Changing newPersonObject changes personObject because they both point to the same object.

Why Is Efficient Heap Memory Usage Important in Node.js?

Memory stored in the heap will live on unless it is explicitly deleted or deallocated. The heap is a large chunk of contiguous memory blocks and is expected to remain so even after allocation and deallocation.

Unfortunately, due to the way heap memory is allocated and deallocated, memory can be wasted and so cause a leak.

To allocate memory in the heap, a program must first scan the heap to find a contiguous memory block large enough to hold the data. If a sufficient chunk is not found, the program will request an increase in heap space. If such a byte of virtual memory is still available in the operating system, it will be provided — but if not, the program will be notified (in some cases, the program may crash).

Since running a program involves accumulating precious virtual memory resources, a program must free memory when it is no longer needed (this is deallocation).

Furthermore, if memory is freed (irrespective of where it's freed in the heap), the heap is expected to merge to maintain the contiguous memory block form. As a result of increased complexity in heap memory, storing memory here can lead to a higher overhead (but comes with greater flexibility).

Although Node.js has an efficient garbage collection mechanism, inefficient use of the heap can lead to memory leaks. An application can use up too much memory or even crash.

Causes of Node.js Memory Leaks in the Heap

The garbage collector tries hard to find and deallocate orphaned memory, but sometimes it may not be able to keep track of every piece of memory. This may result in unnecessary increases in load, especially for large applications. We’ll talk more about how the garbage collector works in Node.js later.

Some of the most common causes of memory leaks include:

  • multiple references
  • global variables
  • closures
  • timers
  • events

It is easy to have several pointers or references to one object. While this may be convenient for you, it can also lead to memory issues if one reference to an object is garbage collected, but others are not.

Forgotten timers and callbacks are two of the most common causes of memory leaks in Node.js and JavaScript applications. Objects tied to timers will not be garbage collected until they time out. If a timer runs forever (which can easily occur in faulty code), objects will never be garbage collected. This will happen even if there are no pointers to the objects and so will create a memory leak in the heap.

Consider the following program:

const language = () => {
  console.log("Javascript");
  setTimeout(() => language(), 1000);
};
Enter fullscreen mode Exit fullscreen mode

The program will run forever and never be garbage collected.

How to Find Memory Leaks in Node.js

Several tools are available to detect and debug memory leaks in Node.js, including Chrome DevTools, Node’s process.memoryUsage API, and AppSignal's garbage collection magic dashboard.

Chrome DevTools

Chrome DevTools is perhaps one of the simplest. To activate the debugger, you need to start Node in inspect mode. Run node --inspect to do this.

More specifically, if your Node's entry point is app.js, you'll need to run node --inspect app.js to debug the Node app. Then, open a Chromium browser and go to chrome://inspect. You can also open the inspector page on Edge at edge://inspect. On the inspector page, you should see a page like this:

Chrome Inspector Devices

Notice that the Node app you are trying to debug appears at the bottom of the inspector page. Click on inspect to open the debugger. The debugger has two important tabs — Memory and Profiler — but we'll focus on the Memory tab in this discussion.

Node Debugger Memory tab

The easiest way to find a memory leak with the Chrome debugger is by taking a heap snapshot. The snapshot helps you inspect some variables or check their retainer size.

You can also compare several snapshots to find a memory leak. For instance, you can take a snapshot before a memory leak, and another snapshot after a memory leak, then compare the two. To take a snapshot, click on Heap snapshot and then the Take snapshot button. This may take some time, depending on your application's Total JS heap size. You can also load existing snapshots by clicking on the Load button at the bottom of the DevTool.

When you have two or more snapshots, you can easily compare the heap allocations to find the source of a memory leak. You can view the snapshot by:

  • Summary - groups the objects in your Node application by their constructor names
  • Comparison - shows the difference between two snapshots
  • Containment - allows you to explore the heap content and analyze objects referenced in the global namespace
  • Statistics

Chrome DevTools Memory Profile Debugger Views

Two columns stand out in the DevTools Heap Profiler — namely, Shadow Size and Retainer Size.

The Shadow Size is the memory size of the object itself. This memory size won't be large for most objects, except arrays and strings. On the other hand, the Retained Size is the memory size freed when the object in question and dependent objects are freed or made unreachable from the root.

Chrome DevTools is not the only way to take a heap snapshot. If you work with Node.js version 12.0 or higher, you can also take the snapshot by running the node --heapsnapshot-signal command.

node --heapsnapshot-signal=SIGUSR2 app.js
Enter fullscreen mode Exit fullscreen mode

While you can use any signal for this, the user-defined signals SIGUSR1 or SIGUSR2 are recommended.

If you want to get a heap snapshot from an application running on a server, it may be better to use the writeHeapSnapshot function from the v8 package.

require("v8").writeHeapSnapshot();
Enter fullscreen mode Exit fullscreen mode

This method is possible in Node.js version 11.13 or higher. In earlier versions, you can achieve the same result with the heapdump package.

A heap snapshot is not the only way to debug memory issues with Chrome DevTools. You can also trace every heap allocation with Allocation instrumentation on timeline.

The allocation timeline shows instrumented memory allocations over time. To enable this, start the profiler, then run your application samples to start debugging memory issues. If you want to record memory allocations for a long-running operation and intend to minimize performance overhead, the best option is the Allocation sampling method.

Node’s process.memoryUsage API

You can also observe memory usage using Node’s process.memoryUsage API. Run process.memoryUsage(), and you'll access the following:

  • rss - amount of memory allocated
  • heapTotal - total size of the allocated heap
  • heapUsed - total amount of memory used when executing the process
  • arrayBuffers - memory allocated for the Buffer instances

AppSignal's Garbage Collection Magic Dashboard

To keep an eye on the growth of your heap, AppSignal has a handy garbage collection magic dashboard. This dashboard is automatically generated for you when you connect your Node.js app to AppSignal!

Check out this example, where you can clearly see a spike in memory usage in the 'V8 Heap Statistics' graph:

Heap dashboard

If this dashboard shows a steady increase, there might be a memory leak in your code, or in a dependency.

Discover more about AppSignal for Node.js.

Now is a good time to look at how garbage collection works.

How Garbage Collection Works

You know how to find memory leaks, but how can you fix them? We'll find out soon, but first, it's important to understand how Node.js and V8 (in general) handle garbage collection.

Garbage collection (GC) frees up (deallocates) memory when it is not needed. To work efficiently, the algorithm for garbage collection must properly define and identify what not needing memory means.

In reference counting GC algorithms, an object in the heap is garbage collected if it no longer has a reference in the stack. This algorithm works by counting references — so if the reference count is zero, the object will be up for garbage collection. Although this algorithm works most times, it fails on circular references.

Consider the following example.

let data = {};
data.el = data;

let obj1 = {};
let obj2 = {};
obj1.a = obj2;
obj2.a = obj1;
Enter fullscreen mode Exit fullscreen mode

Objects with circular references will never go out of scope or be garbage collected, even if they are no longer needed or used. This forms a memory leak and can make your application inefficient. Thankfully, Node.js no longer uses this algorithm for garbage collection.

Rather than counting references, Node.js considers an object ready for garbage collection if it can no longer be reached or referenced (either directly or indirectly) from the root.

The root in Javascript is a global object. In the browser, the root is the window object, but in Node.js, the root is the global object. This algorithm is more efficient than the reference counting algorithm and solves the circular references issue.

Considering the example above, although obj1 and obj2 still have a circular reference, they will be garbage collected if they are no longer reachable from the root (no longer needed).

This algorithm, commonly known as the mark and sweep algorithm, is useful. However, you must be careful and explicitly make an object unreachable from the root to ensure that it is garbage collected.

Fix Memory Leaks in Your Node.js App

Now for the fun part! Here are some ways to improve heap memory allocation and prevent memory leaks.

Avoid Global Variables

Global variables include variables declared with the var keyword, the this keyword, and variables not declared with a keyword.

We’ve established that accidental global variables (and any other form of global variables) can cause memory leaks. They will always be reachable from the root, and so cannot be garbage collected unless they are explicitly set to null.

Consider the following example:

function variables() {
  this.a = "Variable one";
  var b = "Variable two";
  c = "Variable three";
}
Enter fullscreen mode Exit fullscreen mode

These three variables are global variables. To avoid the use of global variables, consider switching the strict mode by adding the use strict directive to the top of the file.

Use JSON.parse

JSON has a much simpler syntax than JavaScript, so it is much easier to parse than JavaScript objects.

As a matter of fact, if you work with a large JavaScript object, you can speed up performance up to 1.7 times in V8 and Chrome by parsing the stringified form as JSON.

There is a possibility of even greater performance in some other JavaScript engines, like Safari. This optimization method is used in Webpack to improve performance in front-end apps.

For instance, rather than using the following JavaScript objects:

const Person = { name: "Samuel", age: 25, language: "English" };
Enter fullscreen mode Exit fullscreen mode

It's more efficient to stringify them and then parse this as JSON.

const Person = JSON.parse('{"name":"Samuel","age":25,"language":"English"}');
Enter fullscreen mode Exit fullscreen mode

Split Large Data Processing into Chunks and Spawn Processes

You might get a weird Heap out of memory error when processing large datasets, such as large CSV files. Of course, you could increase your application’s memory limit to handle the processing, but a better method is to split the data into chunks.

In some cases, scaling your Node.js app on multi-core machines may help. This involves separating your application to master and worker processes. The workers handle the heavy logic, while the master controls the workers and restarts them when they run out of memory.

Use Timers Efficiently

We’ve established that timers can cause a memory leak. To improve heap memory management, ensure that your timers don't run forever.

In particular, when a timer is created with setInterval, it is crucial to call clearInterval when the timer is no longer needed.

It is also a good practice to call clearTimeout or clearImmediate when you no longer need to create a timer with setTimeout or setImmediate.

const timeout = setTimeout(() => {
  console.log("timeout");
}, 1500);

const immediate = setImmediate(() => {
  console.log("immediate");
});

const interval = setInterval(() => {
  console.log("interval");
}, 500);

clearTimeout(timeout);
clearImmediate(immediate);
clearInterval(interval);
Enter fullscreen mode Exit fullscreen mode

Remove Unnecessary Variables in a Closure

Closures are a common concept in Javascript. They are functions (or callbacks) inside another function. If a variable is used in a function, it will be marked for garbage collection when the function returns — but this may not be the case for closures.

Consider the following example:

const func = () => {
  let Person1 = { name: "Samuel", age: 25, language: "English" };
  let Person2 = { name: "Den", age: 23, language: "Dutch" };

  return () => Person2;
};
Enter fullscreen mode Exit fullscreen mode

This function will reference a scope and keep every variable in that scope. In other words, both Person2 and Person1 will be kept in scope when you only need Person2.

As a result, this consumes more memory, resulting in a memory leak. To fix this, only declare variables you need and null out the ones you don’t need.

For instance:

const func = () => {
  let Person1 = { name: "Samuel", age: 25, language: "English" };
  let Person2 = { name: "Den", age: 23, language: "Dutch" };
  Person1 = null;
  return () => Person2;
};
Enter fullscreen mode Exit fullscreen mode

Unsubscribe from Observers and Event Emitters

Observers and event emitters with long lifespans can be a source of memory leaks, especially if you do not unsubscribe from them when they are no longer needed.

Consider the following example.

const EventEmitter = require("events").EventEmitter;
const emitter = new EventEmitter();

const bigObject = {}; //Some big object
const listener = () => {
  doSomethingWith(bigObject);
};
emitter.on("event1", listener);
Enter fullscreen mode Exit fullscreen mode

Here, we retain the memory for bigObject until the listener is released from the emitter, or the emitter is garbage collected. To fix this issue, we need to call removeEventListener to release the listener from the emitter.

emitter.removeEventListener("event1", listener);
Enter fullscreen mode Exit fullscreen mode

It is also possible for memory leaks to occur when there are more than 10 event listeners attached to an emitter. Most times, you can solve this problem by writing more efficient code.

But, in some cases, you may need to explicitly set the max event listener.

For instance:

emitter.setMaxListeners(n); //where n is the new maximum event listener.
Enter fullscreen mode Exit fullscreen mode

Wrap Up

In this post, we explored how to minimize your heap and detect memory leaks in Node.js.

We started by looking at heap allocation in Node, including how the stack and the heap work. We then considered why it's important to keep track of your memory usage and the causes of memory leaks.

Next, we saw how to find memory leaks using Chrome DevTools, Node’s process.memoryUsage API, and AppSignal's garbage collection magic dashboard.

Finally, we discovered how garbage collection works and shared some methods to fix memory leaks in your app.

Like any other programming language, memory management is very important in JavaScript and Node.js. I hope you've found this introduction useful. Happy coding!

Sources and Further Reading

P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.

P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.

Top comments (0)