2009/03/22

ASP.NET menu control optimization

This is the first of my posts regarding Internet Information Services IIS optimization. See the link if you want to follow the whole series.

One of the controls that our website uses the most is the <asp:Menu> control. It is used in the masterpage so that, in the end, it is used at every page of the site, along with breadcrums. I have prepared a sample VS2008 website projects in VB and C# where you can see the facts and follow the steps for yourself. In this sample project, the masterpage sets up several ContentPlaceHolders arranged for a multicolumn webpage. One row at the top contains the logo, breadcrums and menu for the website, a second row with 2 columns contains the left content and main content, and a third row at the end contains the footer with fixed text for all website pages. Of course, if you want to do it right, you should not use <tables> for the layout of the content, you should use <divs> and CSS styles, but that is out of the scope of this post. Here we will only cover and explain a way to optimize your pages that use <asp:Menu> controls.

The layout of the master page is shown using ~/default.aspx in the following image:

Using Fiddler2, the http debugging proxy, we get this file is sized 18214 bytes (17,78 Kb), when browsed through IE7 (see User-Agent string). I strongly recommend Fiddler2 if you want to optimize or debug your web server. It has a lot of useful features, one of the most interesting being the Timeline to see how your server performs in overall (considering all the requests for pages, css files, images, scripts, etc.) graphically, being the time in the X axis. In this case we will just prepare a request using Request Builder and see the results using the Inspector tab:

fiddler request for non-optimal page

Further analysis of the received page, throw these values:

CPH ContentPlaceHolders (2) 1,12 Kb 6,30%
VS __VIEWSTATE 2,81 Kb 15,80%
M Menu contents, scripts & related styles 11,80 Kb 66,37%
T-CPH-VS-M The rest, due to layout (master page) 2,05 Kb 11,53%
T TOTAL 17,78 Kb 100,00%

As you can see, most of the contents of the page is menu-related code. Furthermore, if the menu does not change (very very probable) between subsequent requests of the visitor, we are sending out the same contents again and again, since the menu is in our master page and the same menu related content is rendered for the browser in every page. What a waste of bandwidth (probably money too, if you pay your ISP by traffic) and time for your visitors. Being the bandwidth broader and broader nowadays is no reason for wasting it absurdly.

Besides, if you can read html and see through the generated file, you will see that html code for the menu is in near the top, exactly where we placed the <asp:Menu> control in the masterpage. What would happen if we could delay the load of the menu whist give priority to the real contents of the page? I mean, delay the load of the menu until the contents are shown in the visitor’s browser, and then (afterwards), load the menu. That would increase the responsiveness of the website; the page will not seem stalled while loading a big menu before the actual contents. The users could start reading the contents and in the meantime, even without notice, the menu would appear in its right place.

In subsequent requests, since the menu is already loaded, the visitor would not need to re-download those 11,80Kb (in our case) bytes of menu-related html. In our example, the page of 17,78Kb could be reduced to 1,12 + 2,81 + 2,05 = 5.58 Kb size. The size of the sample page would be 66% smaller, by just stripping out of the page the menu related html and placing it into another page. This can be reduced even more by minimizing the size of the __VIEWSTATE variable, but that will be another post.

The main things to be replaced.

If you read through the html generated code for the menu, you will find several distinguished pieces of code:

  • The <styles> used in the menu, in our example:
    <style type="text/css">
    .ctl00_Menu1_0 { background-color:white;visibility:hidden;display:none;position:absolute;left:0px;top:0px; } 
    .ctl00_Menu1_1 { color:Black;text-decoration:none; } 
    .ctl00_Menu1_2 { color:Black; } 
    .ctl00_Menu1_3 { } 
    .ctl00_Menu1_4 { background-color:Transparent;border-color:Transparent;padding:0px 5px 0px 5px; } 
    .ctl00_Menu1_5 { background-color:White;border-color:Transparent; } 
    .ctl00_Menu1_6 { color:Black; } 
    .ctl00_Menu1_7 { background-color:White;border-color:White;border-width:1px;border-style:solid;padding:0px 5px 0px 5px; } 
    .ctl00_Menu1_8 { background-color:White;border-color:#BBBBBB;border-width:1px;border-style:solid; } 
    .ctl00_Menu1_9 { color:White; } 
    .ctl00_Menu1_10 { color:White;background-color:#BBBBBB;border-color:Transparent; } 
    .ctl00_Menu1_11 { color:White; } 
    .ctl00_Menu1_12 { color:White;background-color:#BBBBBB;border-color:Transparent;border-width:1px;border-style:solid; } 
    </style>
  • Two calls to WebResource.axd for retrieving scripts:
    <script src="/www.mytestsite.com/WebResource.axd?d=Fg4XkH9c9OdEq6bmF8mMjg2&amp;t=633691223257795724" 
      type="text/javascript"></script>
    <script src="/www.mytestsite.com/WebResource.axd?d=-JPtlwQvfdzq429NBDEh_w2&amp;t=633691223257795724"
      type="text/javascript"></script>
  • The actual text for the menu, which is coded using tables (when the browser is IE7) and starts with the string: <a href="#ctl00_Menu1_SkipLink"><img alt...
  • Near the end of the page, there is a script that is also related to the menu, where the object is initialized with the styles and values defined for it. You will find something similar to:
    <script type="text/javascript"> 
    //<![CDATA[ var ctl00_Menu1_Data = new Object(); 
    ctl00_Menu1_Data.disappearAfter = 5000; 
    ctl00_Menu1_Data.horizontalOffset = 0; 
    ctl00_Menu1_Data.verticalOffset = 0; 
    ctl00_Menu1_Data.hoverClass = 'ctl00_Menu1_12'; 
    ctl00_Menu1_Data.hoverHyperLinkClass = 'ctl00_Menu1_11'; 
    ctl00_Menu1_Data.staticHoverClass = 'ctl00_Menu1_10'; 
    ctl00_Menu1_Data.staticHoverHyperLinkClass = 'ctl00_Menu1_9'; 
    //]]> </script>

The problem is that ASP.NET menu control renders differently depending on the User-agent (browser), thus we cannot take this values as fixed constants to create static files with them. However we can still do other thing: Create a simple page with only the menu (between searchable placeholders), self-request this menu-only-file on behalf of the browser making the real request, parse (using regex) and transform the result to create a script file, cache it on the server side too (varying on every user-agent) and return it to the browser (if not a valid cached version already stored).

The steps.

1. Create a standalone menu.aspx file for showing the menu only.

We need to create a ~/resources/ directory under the root of the site (any other name will do the job as long as it is explicitly excluded from being browsed in robots.txt), and as you have imagined, modify your robots.txt and insert:

User-agent: *
Disallow: /resources/ 
Disallow: /WebResource.axd

We will create a simple aspx file (not masterpage based) called ~/resources/menu.aspx and we will insert the <asp:SiteMapDataSource> and <asp:Menu> just as they were in the masterpage (copy & paste) inside the <form> tag. This way we will keep the format and properties of the menu, but get rid of everything else. This page will render just the menu, nothing else. Then surround the start and the end of <asp:Menu> tags with some comments that we will use afterwards when parsing the page to identify exactly where the menu starts and ends (something like <!-- MENU STARTS HERE --> and <!-- MENU ENDS HERE --> will do the job).

menu.aspx

2. Create menu-js.aspx that will be called by the masterpage.

Then we need to create another web form (not masterpage based) that we will call ~/resources/menu-js.aspx. This .aspx file will only have the <% @Page ...> directive, no contents at all at design time. The contents will be generated by the code-behind that will do the parsing of the former menu.aspx page and will be responsible for caching and sending the menu to the client’s browser after having rendered it as a javascript file. The contents of this javascript file that is sent to client’s browser are simply:

var placement = document.getElementById("aspmenu"); 
placement.innerHTML = *** ALL THE MENU CONTENTS ***

This way the menu is rendered after the page has already been loaded and shown in cllient’s browser using javascript, because the call to this menu-js.aspx is near the end of the page. This method works in latest versions of IE, Firefox, Safari, Opera & Chrome, provided that they have javascript enabled. In text only browsers (Lynx and similar) or if javascript is not enabled, this method falls nicely not showing any menu, but keeping the overall appearance provided by the masterpage intact.

3. Create the stylesheet for the menu.

We need to create a ~/resources/menu.css with all the styles that were defined by the original <asp:Menu> control, those named like ctl00_Menu1_xx shown before.

4. Changes in the masterpage.

4.1. Link to the former css file.

You need to include a link to the former css file in the masterpage (see MasterPage-Optimized.master file in the downloadable project, the line is <link href="~/resources/menu.css" rel="stylesheet" type="text/css" />).

4.2. Replace <asp:Menu> by identified <div>.

You must also include an empty <div> tag with id = “aspmenu” in place where the original <asp:Menu> was:

<div id="aspmenu" title="Menu"></div> 

This div tag called aspmenu is the placement where the javascript file will try to insert the real contents of the menu after the page has been loaded. See former point 2, in document.getElementById(“aspmenu”).

4.3. Changes after the <form> tag.

Right after the <form> tag, include a literal control <asp:Literal ID="ltWebResourceMenu" runat="server" EnableViewState="false" />. In the codebehind, this will be set to <script> tags to read the files menu-webresource-axd-a.js & menu-webresource-axd-a.js that we will prepare in next step.

4.4. Changes near the end of the masterpage.

The script that was near the end of a non-optimal page needs to be hard coded now into the master page. Thus, right before the </body> tag, we need to write:

<script type="text/javascript"> 
//<![CDATA[ var Menu1_Data = new Object(); 
Menu1_Data.disappearAfter = 5000; 
Menu1_Data.horizontalOffset = 0; 
Menu1_Data.verticalOffset = 0; 
Menu1_Data.hoverClass = 'Menu1_12'; 
Menu1_Data.hoverHyperLinkClass = 'Menu1_11'; 
Menu1_Data.staticHoverClass = 'Menu1_10'; 
Menu1_Data.staticHoverHyperLinkClass = 'Menu1_9'; 
//]]> 
</script> 
</form> 
<asp:Literal ID="ltMenuScript" runat="server" EnableViewState="false" />

5. Save WebResource.axd used resources as static files under ~/resources/.

In the original non-optimal ~/default.aspx you probably have noticed some lines requesting for a file called WebResource.axd with 2 parameters (d & t). In our case the menu contains some resources that we will grab and save as static files:

Original code Static filename Description
<img src="/www…com/WebResource.axd?d=p51493b-… menu-arrow.gif a right arrow
<script src="/www…com/WebResource.axd?d=Fg4XkH9c9O… menu-webresource-axd-a.js 20,3Kb javascript file
<script src="/www…com/WebResource.axd?d=-JPtlwQvfdz… menu-webresource-axd-b.js 32,4Kb javascript file
<img alt="Skip navigation links" … src="/…/WebResource.axd?d=vlTL… menu-webresource-axd-1x1.gif blank gif

6. Modify menu.aspx to use those static files.

Now that we have saved those resources as static files, we need to modify the menu.aspx we did on step 1, to use these files instead of calls to WebResource.axd. This can be done using the IDE, but the results (in code) should be similar to including these attributes to <asp:Menu> control:

DynamicPopOutImageUrl="~/images/menu-arrow.gif" 
ScrollDownImageUrl="~/images/menu-scroll-down.gif" 
ScrollUpImageUrl="~/images/menu-scroll-up.gif" 
StaticPopOutImageUrl="~/images/menu-arrow.gif"

7. Test the whole thing.

I think I have not left any step behind. Anyway you have the whole projects (in VB and C# , around 28Kb each zip file) to download and see the idea working for yourself. After all, and using Fiddler2, if we request the page ~/default-optimized.aspx, we get the following results:

fiddler request for the optimized-menu page

The file size is 3821 bytes (the original was 18214): that means an improvement of 79% in size reduction!!! Much better than we expected, that is because the size of the __VIEWSTATE has been reduced too (since <asp:Menu> control no longer resides in the page). Of course, the menu-js.aspx still needs to be downloaded, and its size is 9153 bytes (in our example), but using client-side caching, this file only needs to be downloaded once in an hour (Response.Cache.SetExpires(DateTime.Now.AddMinutes(60))).

Another advantage of having the menu rendered in a different file is that the pagerank that any of your pages might have will not dilute its outgoing value among all the rest of the pages due to links in the menu. This way the outgoing links for your pages are much less, only those in the masterpage (that you can easily set to rel=”nofollow”) and those that are real links inside your content. No more outgoing links from any page to any other page because of the menu.

An alternative to my approach for improving performance (and compatibility) of <asp:Menu> control is the use of CSS Friendly Control Adapters. However in that case, the menu is still rendered inside the page (not on a different page request). Their improvement makes a reduction of half the size of the html used to render the menu, by using CSS and <ul> tags instead of <table> tags. Though an improvement (most in compatibility) the improvement we achieve using our approach is much better, since we strip out of any page any menu-related html and place it in another file. By using client-side caching, that file is only requested once per client/connection. Maybe a hybrid solution would be the best: using CSS Friendly Control Adapters and place the html code related to the menu on a different page, but that has not already been done. By now you will need to make your mind up for one or another, you cannot have the best of both in a single solution.

I hope you find this article useful and I am willing to hear your comments about this approach to the problem.

2009-03-23 Update: I have just installed IE8 and checked the well known issue of dropdown menus appearing as blank boxes. Unfortunately, my solution for <asp:Menu> optimization shows the same behavior but has been fixed in both projects ( VB and C#). For more info see my post IE8 breaks asp menu control.

8 comments:

Pathum said...

I’m also seeking this kind of a solution for long period. Thanks for coming up with a great concept. Sample project is working fine. But I’m working in asp.net c# 2.0. Does this work? If you can publish the sample in c# for the same that will be really helps to me.

José Antonio García Barceló said...

I will make the project in c# this weekend and include both versions for people to download. It should be very easy, since there is very little code. Since I was explaining a proof of concept I used .VB to make it simpler for people to understand. Thanks for the suggestion.

Pathum said...

Thanks for uploading c# version. I'm using xml file instead of sitemap for my menu. I tried both scenarios but only sitemap solution work. Can't I use xml file?

José Antonio García Barceló said...

If you had your asp:Menu previously working (whatever the way), just copy your existing menu code, along with the underlying datasource for it to a different file (the one we call ~/resources/menu.aspx), with placeholders for finding where does it start and end. Make sure you can browse that file first and you see the menu as the only output.
The way to retrieve the data for creating the menu is independent from what we do afterwards. We just made the solution using a SiteMapDataSource (which defaults to ~/web.sitemap for the underlying file) but I think you could use a XmlDataSource instead too.
If it doesn't, try debugging a little, there is not much code to trace.

If you cannot find the flaw, drop your email here (I won't publish it) and I'll contact you for working it out.

Pathum said...

Thanks Antonio. Now it's working. You save a headache of mine. Previously one page took nearly 200KB. But after applying this concept I reduced it to 10 KB.

once again thanks a lot for publishing this kind of concept and being helpful to me all these time.

José Antonio García Barceló said...

Thank you for your feedback.

If someone else tries to apply this scheme, I'd be glad to hear about the results and improvements (before/after).

By now, Pathum states that his average page size reduction is around 95%. Awesome!

Anonymous said...

Its working fine as is. But I am using membership and I have to change the XMLSitemap for the users that are logged in. I am ttrying to do it.

Sunil said...

Great work buddy. Thankyou for sharing your ideas. Much Appreciate