Search code examples
node.jsmemorymemory-managementmemory-leaksdynamic-memory-allocation

How to free up memory used by module in NodeJs


I'm trying to free up memory used by an already required module in a NodeJs application. Unfortunately I wasn't able to find a solution, maybe you have experience with memory management in node environment.

I want to dynamically import a module and release its memory when it isn't needed. I've prepared a simplified example to visualize the case.

Service source code:

const Koa = require('koa');
const V8 = require('v8');
const app = new Koa();

app.use(async ctx => {
  if (ctx.request.href.includes('module1')){
    let data = require('./large-module'); // around 30 MB
    console.log(V8.getHeapStatistics());
    ctx.body = `data loaded length: ${data.length}`;
    return;
  }

  if (ctx.request.href.includes('module2')){
    let data = require('./large-module2'); // around 10 MB
    console.log(V8.getHeapStatistics());
    ctx.body = `data loaded length: ${data.length}`;
  }
});

app.listen(3000);

I'm starting application with:

node --max-old-space-size=120 --trace-gc index.js

and at the beginning total_heap_size is around 10 000 000. Then I start periodically querying the endpoint:

watch curl http://localhost:3000/module1

After that memory doesn't increase over time. total_heap_size in first moment hits around 110 000 000, but finally GC reduces it to level of 90 000 000. That's because NodeJs handles case with multiple calls of "require" for the same module (there is no memory leak).

At this point I stop querying /module1 endpoint. total_heap_size is still around 90 000 000, even though we no longer need the large-module.

The problem is when I try to import another file with:

curl http://localhost:3000/module2

It causes heap out of memory error and GC doesn't free up the memory allocated for first module.

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

I've tried to require lighter modules or increase storage for service, but I wasn't able to release the memory reserved by large-module.

I tested different approaches to free up memory, but none of them work:

  1. delete require.cache[require.resolve('./large-module')]; - it causes memory leak for multiple "require" calls for the same module, because we remove only reference but GC doesn't free up memory allocated for this module.
  2. decache('./large-module'); decache library - same like point above
  3. clearModule('./large-module'); clear-module library - same like point above
  4. I've tried also to run GC manually with global.gc() - case described in this issue

Do you know proper way to free up already allocated memory?


Solution

  • I've done the research, so I can tell you more about the conclusions. I conducted a test on dummy NodeJs service. It was based on multiple querying of the endpoint that dynamically registered the module (around 15 000 times). I used for it Apache HTTP server benchmarking tool, as follows:

    ab -n 1000 -c 5 https://my-service/my-endpopint
    

    I was measuring memory consumption on the fly and displayed it on a graph.

    1. How does the memory usage behave when I use the decache function (from decache library) after require of the each module. enter image description here

    2. The same curve characteristics when I use the clear-module library. enter image description here

    3. For comparison, a graph of how it behaves when I require a module multiple times without any decache or clear-module library. NodeJs as default doesn't require the package multiple times. However, NodeJs didn't release the allocated memory while the application was running. enter image description here

    As I mentioned before, all of the above attempts ended up with more or less memory leakage. However, we found a solution to avoid this. We used external process, in simple terms it looks like below (module from my example exports JSON):

    execPromise(`node -p 'JSON.stringify(require("${modulePath}"))'`, {
      maxBuffer: 1024 * 1024 * 10, // 10 MB
    }
    

    However, we must take precautions not to create too many processes at once. For this we used the p-queue library. Finally, the code looks like this:

    const { promisify } = require('util');
    const { exec } = require('node:child_process');
    const { default: PQueue } = require('p-queue');
    
    const execPromise = promisify(exec);
    const moduleReadersQueue = new PQueue({
      concurrency: 4,
      timeout: 1000,
    });
    
    const processOutput = await moduleReadersQueue.add(
      () => execPromise(`node -p 'JSON.stringify(require("${modulePath}"))'`, {
        maxBuffer: 1024 * 1024 * 10, // 10 MB
      }),
    );
    
    const { stdout: moduleOutput } = processOutput;
    result = JSON.parse(moduleOutput);
    

    The final graph of memory consumption looks as follows. We can see increases when the module is required and decreases when the process has ended and the memory is released. GC cleans unused variables as opposed to required modules.

    enter image description here