DEV Community

Asaf Federman
Asaf Federman

Posted on

The Hard-Coded Crash: A Story of Dynamic Heap Allocation in Node.js

Introduction

Deploying a Node.js application in Docker or Kubernetes often brings challenges with setting memory limits. For years, developers had to specify a fixed heap size for the V8 engine using the --max-old-space-size flag. This solution never felt right in the dynamic, container-based world we live in.
This post explores the story behind a new feature designed to tackle this problem: the --max-old-space-size-percentage flag.

A Tale of Two Crashes

Let's set the scene: your Node.js application is happily running in a Kubernetes pod. You've followed best practices by setting a memory limit using the --max-old-space-size flag.
Then everything goes haywire. Alerts start flashing, and soon enough, your application crashes with a JavaScript heap out of memory error. The operations team rolls up their sleeves and increases the container's memory limit from 4GB to 6GB, thinking that should do the trick.
But the application crashes again. What's going wrong?
This situation isn’t just hypothetical; it played out with an Express.js application designed to fill its memory. The app was running with a hard-coded heap limit of 3GB (--max-old-space-size=3000).
Here’s what happened in a 4GB container:

docker run --rm -p 9999:9999 --memory=4g example node --max-old-space-size=3000 ./src/index.js
Server running on http://localhost:9999
Node.js Heap Limit is: 3192.00 MB
Attempting to fill the heap. Current usage: 7.65 MB
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
Enter fullscreen mode Exit fullscreen mode

And here it is again in a 6GB container:

docker run --rm -p 9999:9999 --memory=6g example node --max-old-space-size=3000 ./src/index.js
Server running on http://localhost:9999
Node.js Heap Limit is: 3192.00 MB
Attempting to fill the heap. Current usage: 7.64 MB
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
Enter fullscreen mode Exit fullscreen mode

Both crashes were identical because the Node.js memory limit was set statically and not dynamically. As such, Node.js did not attempt using the additional memory, even though it was available.


The Problem Developers Faced

The core of the issue was the --max-old-space-size flag. While powerful, it requires a static value in megabytes, which can be a limiting factor in modern cloud environments that benefit from automatic resource adjustments, such as the Kubernetes Vertical Pod Autoscaler (VPA). A fixed heap size just can’t adapt to these changes.

This creates ongoing trade-offs:
● Allocate too much memory: The process risks getting OOM killed when memory is under pressure.
● Allocate too little: This may either lead to wasted resources or crashes due to an OOM killed error.

Many developers resorted to writing custom shell scripts to inspect the container's available memory on startup and then calculate a value to pass to Node.js. Unfortunately, this approach was cumbersome and often error-prone.


The Proposed Solution

The most intuitive solution was to introduce a new flag that accepts a percentage value - something already seen in other runtimes like the JVM. I proposed this idea in a GitHub issue on the nodejs/node repository (#57447).
Instead of a fixed number, developers could provide a percentage using the new --max-old-space-size-percentage flag:

# Use 80% of the available memory for the heap
node --max-old-space-size-percentage=80 app.js
Enter fullscreen mode Exit fullscreen mode

This new approach allows a Node.js process to dynamically adjust its heap size based on the memory available to its container. An application can now fully utilize its allocated resources, whether it's in a 1GB or a 16GB container, eliminating the need for complex startup scripts or code changes.


From Issue to Feature: The Path to Contribution

This feature is a great example of the open-source contribution journey. It began as a discussion in the GitHub issue mentioned above, where the real-world pain was brought up.
The process wasn't always easy. Initially, I found it challenging to explain why this feature was important and how it would benefit developers. Engaging with the community took time, but the constructive feedback I received was invaluable.
Finding the right place to propose the solution was another hurdle. With a vast codebase and many contributors, identifying where to suggest the new feature felt overwhelming at times. Coding in an unfamiliar environment added complexity and made the contribution a learning experience.
Despite these challenges, I managed to submit a formal pull request. This journey demonstrated how the Node.js community thrives on collaboration, allowing members to identify practical problems and contribute solutions that become part of the core runtime, benefiting all developers.


The Dynamic Solution in Action

The result of this journey was the --max-old-space-size-percentage flag, allowing Node.js applications to adapt dynamically based on the container's memory availability.
Let’s revisit the test. Here are the JavaScript and the Dockerfile files used to generate the aforementioned results:

src/index.js

const express = require('express');  
const v8 = require('v8');
const app = express();
const PORT = 9999;
let memoryHog = [];
app.get('/', (req, res) => {
   const memoryUsage = process.memoryUsage();
   res.send(`
       <h1>V8 Heap Crash Test</h1>
       <p>Heap Limit: ${getHeapLimitMB()} MB</p>
       <p>Heap Used: ${(memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB</p>
       <button onclick="window.location='/grow'">Click to Fill Heap & Crash Immediately</button>
       <button onclick="window.location='/reset'">Click here to reset</button>
   `);
});
app.get('/grow', (req, res) => {
   console.log(`Attempting to fill the heap. Current usage: ${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`);
   const objectsToAllocate = 30_000_000;
   for (let i = 0; i < objectsToAllocate; i++) {
       memoryHog.push({
           index: i,
           data: `This is object number ${i} in the memory hog array.`
       });
   }
   console.log('If you see this message, the application did not crash.');
   res.send(`Heap fill attempt complete. If the application is still running, it did not crash.`);
});
app.get('/reset', (req, res) => {
   memoryHog = [];
   if (global.gc) { global.gc(); }
   console.log('Memory reset.');
   res.redirect('/');
});
function getHeapLimitMB() {
   return (v8.getHeapStatistics().heap_size_limit / 1024 / 1024).toFixed(2);
}
app.listen(PORT, () => {
   console.log(`Server running on http://localhost:${PORT}`);
   console.log(`Node.js Heap Limit is: ${getHeapLimitMB()} MB`);
});
Enter fullscreen mode Exit fullscreen mode

Dockerfile

FROM node:22-bullseye-slim
WORKDIR /usr/src/app
COPY . .
RUN npm install
Enter fullscreen mode Exit fullscreen mode

Using the Docker Image

To build and run the Docker image for this application, follow these steps:

  1. Build the Docker Image: Navigate to the directory containing the Dockerfile and the src folder, then run the following command to build the image: docker build -t max-old-space-size-percentage-example .
  2. Run the Docker Container: After the image is built, you can run it with a specified memory limit. For example, to allocate 6GB of memory and utilize 80% for the heap, use: docker run --rm -p 9999:9999 --memory=6g max-old-space-size-percentage-example node --max-old-space-size-percentage=80 ./src/index.js
  3. Access the Application: Open your web browser and go to http://localhost:9999 to interact with the application. You’ll see options to fill the heap (/grow) and reset memory (/reset).

Let’s run the application with the new flag:

docker run --rm -p 9999:9999 --memory=6g example node --max-old-space-size-percentage=80 ./src/index.js
Server running on http://localhost:9999
Node.js Heap Limit is: 5107.00 MB
Attempting to fill the heap. Current usage: 7.61 MB
If you see this message, the application did not crash.
Enter fullscreen mode Exit fullscreen mode

No more crashes! 🎉 The flag worked perfectly, calculating a new heap limit of about 5.1GB based on the available 6GB of memory in the container. The application handled its workload effortlessly. It’s important to note that while the --max-old-space-size-percentage flag specifically adjusts the old generation memory, the method v8.getHeapStatistics().heap_size_limit returns the total heap size.


Conclusion: A Small Change with a Big Impact

The new --max-old-space-size-percentage flag is a practical solution to a long-standing problem for developers running Node.js in containers. This single feature leads to several key benefits:
● Simplified Configuration: It removes the need for complex shell scripts to calculate memory limits.
● Improved Resilience: Applications can dynamically adapt to their environments, reducing the risk of being OOM killed.
● Efficient Resource Use: It ensures processes can scale their memory usage to fully leverage the resources allocated to their containers.

Ultimately, this change strengthens Node.js's position as a go-to option in the modern, cloud-native world. 🚀


Join the Discussion

What do you think about this new feature? How will it impact the way you configure and deploy your Node.js applications? I’d love to hear your experiences in the comments below!

Additional Resources:

● The initial issue: GitHub Issue #57447
● The pull request: GitHub Pull Request #57493
● The official documentation: Node.js Documentation

Top comments (1)

Collapse
 
ido_ilani_1842d8ff624de0a profile image
Ido Ilani

@asaffederman Great job on your determination I remember when you originally first presented the problem, and seeing the solution now is simply amazing