How ASP.NET MVC Routing Works and its Impact on the Performance of Static Requests

Recently a number of people have asked how MVC and ASP.NET routing impacts the performance of static requests (HTML, JPG, GIF, CSS, JS, etc). I'll answer that question below while explaining how routing is implemented in the ASP.NET pipeline on IIS 6 and IIS 7. This applies to both ASP.NET 3.5 and 4.0, unless otherwise stated. I'll also talk about the new extensionless URL routing feature in ASP.NET 4.0.

How ASP.NET Routing Works

URLs are mapped to handlers by IIS when the request is initially received. I'll refer to the ASP.NET handlers as managed handlers, and everything else as unmanaged handlers. A request with a managed handler will enter ASP.NET and be processed by the various managed modules in the pipeline. On IIS 6, a request with an unmanaged handler won't be processed by managed code. On IIS 7, a request with an unmanaged handler, also, won't be processed by managed code, at least not by default.

The first step to make routing work for requests with unmanaged handlers is to direct these requests into managed code. On IIS 6 this is done with a wildcard mapping to aspnet_isapi.dll. On IIS 7 this is done either by setting runAllManagedModulesForAllRequests=”true” or removing the "managedHandler" preCondition for the UrlRoutingModule. Ok, so now the requests are coming into managed code, what happens next? Well, the UrlRoutingModule inspects the requests and will change the handler mapping according to the route table. If it doesn't change the handler mapping, then the original handler mapping will be used--the one set by IIS. It's actually a bit more complicated on IIS 6 because of the wildcard mapping. On IIS 6, if the UrlRoutingModule doesn't change the handler, ASP.NET will issue a child request that allows the request to execute with the handler mapping that would have been applied originally if the wildcard mapping didn't exist. Make sense?

Impact on Performance of Static Requests

Okay, so what about the performance of static requests (HTML, JPG, GIF, CSS, JS, etc)? If a request enters managed code, the throughput for that request will be reduced to *approximately* 1/3 of what it would have been, assuming that no caching is involved. Note that I said *approximately*, because the actual result will depend upon hardware, software, the size of the file, etc. Fortunately kernel caching comes to the rescue on IIS 7. If you have an application that uses routing in such a way that *all* requests enter managed code, as long as you don’t change the handler for static requests, they will be kernel cached on IIS 7 (if they’re hot, and meet kernel cache requirements).  What about kernel caching on IIS 6? Unfortunately kernel caching is disabled for child requests on IIS 6, which means enabling ASP.NET routing on IIS 6 effectively disables kernel caching of static requests.

So does that mean I should put my static content in an application that isn’t using routing, if I'm concerned about the performance of static requests? On IIS 6, the kernel cache is effectively disabled for static content when ASP.NET routing is enabled; so yes, you proably should partition your site. On IIS 7 it's only the first couple requests to the static file that miss the kernel cache, assuming the request meets kernel caching requirements. However, there is a better way to enable ASP.NET routing on IIS 7, and ASP.NET 4.0 makes use of it by default. Keep reading and I'll also tell you how to enable this on ASP.NET 3.5.

ASP.NET 4.0 Enables Routing of Extensionless URLs

In ASP.NET v4.0, there is a better way to enable routing. Normally you're only interested in routing extensionless URLs, and have no need to route static requests (HTML, JPG, GIF, CSS, JS, etc). In v4.0 there is a new feature that allows extensionless URLs to be directed into managed code, without impacting static requests (HTML, JPG, GIF, CSS, JS, etc). Because of this feature, on IIS 6 you no longer need a wildcard mapping and on IIS 7 you no longer need to set runAllManagedModulesForAllRequests=”true” or remove the "managedHandler" precondition for the UrlRoutingModule. It works by default on both IIS 6 and IIS 7, except that you need a QFE from the IIS team to make this work on Windows Vista SP2, Windows Server 2008 SP2, Windows Server 2008 R2, and Windows 7. Once you obtain the IIS 7 QFE and install v4.0 ASP.NET, you’ll be able to route extensionless URLs without impacting static requests. The QFE enables a new “*.” handler mapping—the notation may seem weird, but all you care about is the fact that this maps to URLs without an extension. ASP.NET registers a “*.” handler mapping when v4.0 is installed. If you don’t have the IIS 7 QFE, that handler mapping does nothing. If you have the IIS 7 QFE, extensionless URLs are mapped to our handler, which enables them to be routed by the UrlRoutingModule. Information about the IIS 7 QFE and steps to download it can be found at https://support.microsoft.com/kb/980368. For the record, the implementation of this feature on IIS 6 is done quite differently, and I won't go into that here.

Just in case you need to disable the feature, I'll tell you how.  On IIS 6, you can disable the feature by setting a DWORD registry key at HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\ASP.NET\4.0.30319\EnableExtensionlessUrls = 0 (the default is 1, even when the key does not exist).  On IIS 7, you can disable the feature by removing the "*." handler mappings. There are three of them (1 for 32-bit classic mode, 1 for 64-bit classic mode, and 1 for integrated mode) and the names of these handler mappings in applicationHost.config are: "ExtensionlessUrl-ISAPI-4.0_32bit", "ExtensionlessUrl-ISAPI-4.0_64bit", and "ExtensionlessUrl-Integrated-4.0".

How to Enable Extensionless URL Routing on ASP.NET 3.5 for MVC

Similarly to the way it is done in ASP.NET 4.0, you can enable routing of extensionless URLs on ASP.NET 3.5 (and even ASP.NET 2.0) without setting runAllManagedModulesForAllRequests="true" or configuring a wildcard script handler.  To do this, you need one of the following operating systems:  Windows Vista SP2, Windows Server 2008 SP2, Windows Server 2008 R2, or Windows 7.  And you need to patch it with the hotfix available at https://support.microsoft.com/kb/980368 (this hotfix will eventually be rolled into a future service pack, of course).  Once you have the hotfix installed, simply add the following web.config to your application and take the source code for MyTransferRequestHandler (also below) and put it in a CS file located in the App_Code folder of your application.  Alternatively you can compile MyTransferRequestHandler into a DLL and place it in the bin, or better yet, give it a strong name, install it in the GAC, and NGEN it.  After doing this, extensionless URLs will be directed into managed code, and you can then route them to a different handler.  If you choose not to route them to a different handler, they will be directed back to IIS so that they can be executed by the handler that would have served them if our extensionless URL handler was not installed.

"web.config":

 <configuration>
  <system.webServer>
    <handlers>
      <!-- THESE HANDLER MAPPINGS SHOULD GO BEFORE 
           ANY OTHER HANDLER MAPPINGS -->
      <add name="EURL-ISAPI-2.0_32bit" 
        path="*." 
        verb="GET,HEAD,POST,DEBUG" 
        modules="IsapiModule"
        scriptProcessor="%SystemRoot%\Microsoft.NET\Framework\v2.0.50727\aspnet_isapi.dll" 
        preCondition="classicMode,runtimeVersionv2.0,bitness32" 
        responseBufferLimit="0"/>
      <add name="EURL-ISAPI-2.0_64bit" 
        path="*." verb="GET,HEAD,POST,DEBUG"
        modules="IsapiModule" 
        scriptProcessor="%SystemRoot%\Microsoft.NET\Framework64\v2.0.50727\aspnet_isapi.dll" 
        preCondition="classicMode,runtimeVersionv2.0,bitness64" 
        responseBufferLimit="0"/>
      <add name="EURL-integrated-2.0" 
        path="*." 
        verb="GET,HEAD,POST,DEBUG" 
        type="MySample.MyTransferRequestHandler" 
        preCondition="integratedMode,runtimeVersionv2.0"/>
      <!-- PLACE ADDITIONAL HANDLER MAPPINGS BELOW HERE -->
    </handlers>
    <modules runAllManagedModulesForAllRequests="false">
      <!-- BE CERTAIN TO SET runAllManagedModulesForAllRequests="false" -->
      <!-- AND IF YOU ADDED A WILDCARD SCRIPT MAPPING, REMOVE THAT TOO -->
    </modules>
  </system.webServer>
</configuration>

 

"App_Code/MyTransferRequestHandler":

 namespace MySample {
    using System;
    using System.Web;
    // This handler only works on IIS 7 in integrated mode and is
    // designed to be used as a "*." handler mapping.  We will refer
    // to "*." as the extensionless handler mapping.  There is a bug
    // in Windows Vista, Windows Server 2008, and Windows Server 2008 R2
    // that prevents the IIS 7 extensionless handler mapping from working
    // correctly, and if you have not already patched your machine you 
    // will need to install this hotfix: https://support.microsoft.com/kb/980368.
    // 
    // WARNING: You may be wondering how this handler works, since it passes
    // the original URL to the TransferRequest method.  Why doesn't it do the
    // same thing the second time the URL is requested?  Well, TransferRequest
    // invokes the IIS 7 API IHttpContext::ExecuteRequest, and includes the
    // EXECUTE_FLAG_IGNORE_CURRENT_INTERCEPTOR flag in the third argument to
    // ExecuteRequest.  This causes the "*.", or extensionless, handler mapping
    // to be skipped, and allows the handler that would have executed originally
    // to serve this request. If you attempt to use this handler without an
    // extensionless handler mapping, it will probably result in recursion.
    // This recurssion will eventually be stopped by IIS once the loop iterates
    // about 12 times, and then IIS will respond with a 500 status, a message 
    // that says "HTTP Error 500.0 - Internal Server Error", and an HRESULT  
    // value of 0x800703e9.  The system error message for this HRESULT is
    // "Recursion too deep; the stack overflowed.".
    public class MyTransferRequestHandler : IHttpHandler {        
        public void ProcessRequest(HttpContext context) {
            context.Server.TransferRequest(context.Request.RawUrl, 
                                           true /*preserveForm*/);
        }       
        public bool IsReusable {
            get {
                return true;
            }
        }
    }
}

 

Wondering how the extensionless URL handler mappings work? Wondering why it works for both classic and integrated mode? In classic mode, the "*." handler mapping is to our aspnet_isapi.dll. Extensionless URLs will be mapped to the DefaultHttpHandler in classic mode. The DefaultHttpHandler is listed in the <httpHandlers> section in the root web.config file with path="*", so it catches everything that is not caught by something else. If you don't change the handler before the MapRequestHandler event, the DefaultHttpHandler will issue a child request to the original URL with the HSE_EXEC_URL_IGNORE_CURRENT_INTERCEPTOR flag. This tells IIS to skip our extensionless URL handler and map the request to the handler that would normally serve this type of request. If on the hand you want ASP.NET to handle it, you can change the handler by calling HttpContext.RemapHandler before the MapRequestHandler event. This is what ASP.NET routing does in v4.0--in v3.5, routing essentially does the same, but in a much more round about manner. So that's how the extensionless URL handler works in classic mode. In integrated mode, it's essentially the same, but instead of going through the ISAPI path we're able to call the IIS API IHttpContext::ExecuteRequest directly, and similarly we pass a flag to ignore the current interceptor, but the flag is called EXECUTE_FLAG_IGNORE_CURRENT_INTERCEPTOR. The ISAPI code path actually uses these same APIs, but not directly.

Troubleshooting

Having trouble with the MyTransferRequestHandler shown above? That code only directs extensionless URLs into managed code. You need to use routing or something else in order to handle them. If you just want to see if extensionless requests are entering managed code, you can add the following to your web.config and put the MyInterceptorModule source code in a file named MyInterceptorModule.cs within your App_Code directory. The MyInterceptorModule intercepts all requests that enter managed code and displays information about the URL and the values of several server variables.

 

"web.config additions":

 <system.web>
    <httpModules>
      <add 
        name="MyInterceptorModule" 
        type="MySample.MyInterceptorModule"/>
    </httpModules>
  </system.web>
  <system.webServer>
    <validation validateIntegratedModeConfiguration="false"/>
    <modules runAllManagedModulesForAllRequests="false">
      <add name="MyInterceptorModule" 
        type="MySample.MyInterceptorModule" 
        preCondition="managedHandler"/>
   </modules>
  </system.webServer>

 

"App_Code/MyInterceptor":

 namespace MySample {
    using System;
    using System.Web;
    public class MyInterceptorModule : IHttpModule {
        public void Dispose() {}
        public void Init( HttpApplication app ) {
            app.BeginRequest += new EventHandler( OnBeginRequest );
        }
        private void OnBeginRequest(object sender, EventArgs e) {
            HttpApplication app = sender as HttpApplication;
            HttpResponse response = app.Context.Response;
            HttpRequest request = app.Context.Request;
            response.Output.WriteLine("<html><head><title>intercepted</title></head><body><pre>");
            response.Output.WriteLine("Path={0}", request.Path);
            response.Output.WriteLine("PathInfo={0}", request.PathInfo);
            response.Output.WriteLine("QueryString={0}", request.QueryString);
            response.Output.WriteLine("RawUrl={0}", request.RawUrl);
            foreach(string key in request.ServerVariables.AllKeys) {
                response.Output.WriteLine(
                       "{0} = {1}", key, request.ServerVariables[key]);
            }
            response.Output.WriteLine("</pre></body></html>");
            app.CompleteRequest();
        } 
    }
}

 

 

Note that if you're experimenting and want to see what is kernel cached, there are a number of things that prevent a resource from being kernel cached. Be sure to include an Accept-Encoding header, for example most browsers include "Accept-Encoding: gzip,deflate". Also don't issue a conditional GET, for example, make sure your browser is not using "If-Modified-Since". Request the page a couple times in order to kernel cache it. To see if it is served from the kernel cache, start Performance Monitor (Start|Run|type perfmon.msc) and add the following performance counters: "Web Service Cache\Kernel: URI Cache Hits", "Web Service Cache\Kernel: URI Cache Misses", and "Web Service Cache\Kernel: Current URIs Cached".

Regards,
Thomas