Nuxt Server-Side Memory Leak Using SSR (Server Side Rendering)

Hello, Habr! This article is a must-read for anyone who works with Vue SSR, in particular with Nuxt . This is about a memory leak when using axios .

Background


Half a year ago, I got on a project with a VueJS + Nuxt stack, its peculiarity was that the Nod servers (Nuxt) were constantly dying in the prod and new ones were rising in their place. According to the graphs and logs, it was clear that the node process operative reached 100% and it fell with an out of memory error. At this time, a new one was rising to the place of the killed process, which took about 30 seconds, this was enough for users to get a 502 error. Obviously, somewhere in the code there was a memory leak that needed to be found.

I want to highlight the key points right away, since reading only part of this article may not answer all your questions:

  1. Relevance of the topic
  2. Axios interceptors
  3. runInNewContext

1. Relevance of the topic


The first thing, as many of us would do, I started looking for a solution on the Internet, my queries looked something like this: NodeJS memory leaks , nuxt memory leaks , nuxt memory leaks in production , etc.

Of course, none of the twenty issue on stackoverflow helped me, but I learned how to track memory usage through chrome: // inspect. To my disappointment, I found that 90% of all memory that was not cleaned for some reason was some Vue's functions like renderComponent, renderElement, and others.



1. Axios Interceptors


We quickly go through my torment in search of a problem and immediately proceed to the fact that axios.interceptors are to blame for everything (I'm sorry, Habr, for finding the guilty).

Immediately make a reservation that axios was created like this:

import baseAxios from 'axios';

const axios = baseAxios.create({
  timeout: 10000,
});


export default axios;

And attached to the application context like this:

import axios from './index';

export default function(context) {

  if(!context.axios) {
    context.axios = axios;
  }
}

  • After a long search for leaks, I found that if you disable all axios.interceptors, then the memory starts to be cleaned.
  • What is the matter?
  • interceptor is a proxy that intercepts all response or request and allows you to execute any code with an answer (for example, handle errors) or add something before sending a request globally for all requests and in 1 place, convenient, isn't it? Here is an example of how it looks (file 'plugins / axios / interceptor.js')

export default function({ axios }) {

  const interceptor = axios.interceptors.response.use( (response) => {
    return response;
  }, function (error) {
    //-   ,  
    return Promise.reject(error);
  });

}

And here the fun begins. We add the function of adding an interceptor via plugins in nuxt.config.js

  plugins: [
    { src: '~/plugins/axios/bindContext' },
    { src: '~/plugins/axios/interceptor' },
  ]

and nuxt automatically for each new request performs all plugins functions, then does nuxtServerInit and then everything is as usual. That is, for the first user, we create an interceptor on the server side, somewhere in our components in asyncData or in fetch we make requests, and the interceptor works out as it should, then the second user enters and we create the second interceptor and the code inside the function will work 2 times!

For a better understanding of my words, I will draw a counter that increments with every call to the function and 5 times knock on index.



We can notice that 15 calls have occurred, and this is 1 + 2 + 3 + 4 + 5, I additionally took the time to create the next interceptor to make sure that there are challenges of those that were created earlier.

From school, we all remember the formula of arithmetic progression well, and the sum from 1 to n can be written as n * (n + 1) / 2. It turns out that when the 1000th user comes in, our function will be called 1000 times, and in total this is already half a million calls, so if the load is medium or high, then do not be surprised if your server crashes.

Solution to the problem


UPD. Solution # 0 - The comments describe good solutions to this problem.

Solution # 1 - Do not use axios.interceptors.

Solution No. 2 - Everything is very simple, you need to clean the interceptor for yourself, guided by the axios documentation

export default function({ axios }) {

  const interceptor = axios.interceptors.response.use( (response) => {
    
    if(process.server) {
      axios.interceptors.response.eject(interceptor);
    }
    
    return response;
  }, function (error) {
    if(process.server) {
      axios.interceptors.response.eject(interceptor);
    }
    
    return Promise.reject(error);
  });

}

This needs to be done only on the server side, because otherwise on the client side, after successfully executing any first request, this interceptor will stop executing. There is one more nuance with the fact that while we are still on the server and are processing the requests of the next user, but there may be several, but several requests, then with the eject of this interceptor, all requests except the first will not go through it, in this case to independently think about the moment at which you need to perform an eject, the easiest way to do this is through setTimeout, for example, after 10 seconds, then we can assume that on the server side we will manage to complete all requests for the current user and all of them will be executed during this time, when the interceptor will still be active.

runInNewContext


This is a very funny option, because of which this bug cannot be reproduced locally, but it is very easily reproduced in the build. Read about it here . When I was preparing to write this article, I created the starter-template nuxt project to reproduce this problem, and how I was surprised that for each regular user - interceptor was executed 1 time, and not n. The thing is, when we write npm run dev - this option is true by default, and every time we perform functions from plugins on the server side, the context is new each time (obviously from the flag name), and it is automatically done in the build false for better performance in the prod, so I had to disable this option in nuxt.config.js


render: {
    bundleRenderer: {
      runInNewContext: false,
    },
  },

Conclusion


As for me, this problem is very serious, and it is worth paying special attention to it. Perhaps this problem concerns not only Vue ssr, but also others, and not only axios, but also any other HTTP clients that have proxies similar to interceptor. If you have questions, you can write to me on Telegram @alexander_proydenko . All the code used in the article can be viewed on github here .

All Articles