Lesson learned in refactoring ASP.NET MVC3 routes

What's the problem, Doc?

Typical ASP.NET MVC applications will not face performance issues related to routing. General applications will have one default route ({controller}/{action}/{id}) and several custom ones. Our own custom-built CMS catches all non-custom routes. Therefore, we added a separate and distinct route for every action. You can say that our application is not a typical one – it has a large number of custom routes (approximately 300). Why do a large number of routes lead to performance issues? Let’s find out.

Always do profiling before refactoring

Some time ago, we noticed that one of our client’s websites was getting slower. Because of this, we started investigating performance issues. The site has rather heavy client-side JavaScript logic, so we assumed that we needed to tweak our JavaScript code. However, before jumping into JavaScript optimization, we wanted to make sure that our assumption was correct. Therefore, we started profiling our web application. The results were rather surprising. The performance issue was related to server-side logic. Response time from the server was 2 to 3 seconds, even without any back-end logic. 2 to 3 seconds just to render a simple page without any content on it! After a few hours of intensive profiling, debugging, and head scratching, we finally isolated the source of the problem. Performance issues were somehow related to the MVC routing table. Profiling results showed that a large portion of CPU time was used by MVC routing methods. The profiler showed that the GetVirtualPath method in the RouteCollection class was called one hundred times while rendering one page.

Look into the sources, Luke

Last year, Microsoft released the source code of ASP.NET MVC3 to the public. We downloaded the ASP.NET MVC 3 source code and started analyzing it. The GetVirtualPath method contains a loop that iterates through all configured routes and finds the first one that returns a match. Here is the source of the GetVirtualPath method:

Code 1

This code fragment traverses all routes in the route table to find a match. When the route table grows, the time it takes to find a match grows as well.

We also searched the MVC3 source code to find places where the GetVirtualPath method was referenced. This method was used in many other places, like in common Html helpers. Here is the path from Html.ActionLink to GetVirtualPath.

LinkExtenions.cs ActionLink => HtmlHelper.cs GenerateLink => HtmlHelper.cs GenerateLinkInternal => UrlHelper.cs GenerateUrl => RouteCollection.cs GetVirtualPathForArea => RouteCollection.cs GetVirtualPath

When the number of custom routes increases, your web application starts to get slower. If you use Html helpers like ActionLink, response time increases even more. Every call to ActionLink executes the same loop in the GetVirtualPath method. Theoretically, the GetVirtualPath method can be executed one hundred times for one request if you use many Html helpers. Response time is also affected by the place of matched route in the routing table. The closer the route is to the beginning of the table, the quicker the match is found, and the quicker the GetVirtualPath loop terminates itself.

Give me some numbers...

I have created a simple MVC application to emulate route-related performance issues. In its essence, this application is identical to Microsoft’s VisualStudio MVC template. I have only added custom routes into the Global.asax file as needed.

Code 2

I have also modified the Views/Home/Index.cshtml file and have added calls to ActionLink to it.

Code 3

In the first test, I added custom routes to the routing table to see how this affects response times. I have recreated a worst-case scenario: the matched route is at the end of the routing table. I have started from 5 routes and increased the number of routes by 10 for each subsequent iteration. In the last run, there were 100 routes in the routing table.

In the second test, I also added calls to @Html.ActionLink helper into the index view. This is a mixed scenario, because the matched route is at the end of the routing table, but the ActionLinks in the index view points to every route in the routing table. I used the same number of routes in the routing table and helpers in the index view. I started from 5 routes and increased the number of routes by 10 for each subsequent iteration. In the last run, there were 100 custom routes in the routing table and 100 helpers in the index view.

Test setup

All tests were performed on my development machine. The test application was built in release mode and hosted on IIS server to emulate the production environment. I used Apache jMeter to generate some traffic to the test site. Here is the jMeter configuration I used for testing:

Test Setup

I started with the first test scenario with 5 custom routes. I ran the test five times with 5 routes and it took an average value of response times. After that, I added 10 more custom routes and repeated my previous actions. For the last test in the first scenario, I ran the test with 100 custom routes in the routing table. I did the same with the second test scenario afterwards.

Test results

The graph below shows results for the first test scenario. The X axis shows the number of custom routes in the routing table. The Y axis shows response time.

Chart 1

The graph below shows results for the second test scenario. The X axis shows the number of custom routes in the routing table and calls to ActionLink helper. The Y axis shows response time.

Chart 2

The graph shows that the number of calls to ActionLink helpers also increases response time. If you have a simple page with 100 ActionLinks and a route table with 100 custom routes, the response time is almost 2 seconds. Keep in mind that this simple application has no back-end logic and only 100 links, while the response time is ~2seconds!

What can you do about it?

The most important rule – keep your route table as small as possible and use the default route as much as possible.

Find the most used routes and move them into the beginning of the routing table. This way, you will ensure that the GetVirtualPath method loop will find a match and terminate itself as soon as possible.

Find custom routes that are not visible to the user or not important for backward compatibility and remove them from the routing table.

Never miss a beat.

Sign up for our email newsletter.